mirror of
https://github.com/SukkaW/Surge.git
synced 2026-09-12 18:44:36 +08:00
Perf: fast sing-box JSON print
This commit is contained in:
@@ -19,7 +19,6 @@ import { OUTPUT_INTERNAL_DIR, SOURCE_DIR } from './constants/dir';
|
||||
import { DomainsetOutput, AdGuardHomeOutput } from './lib/rules/domainset';
|
||||
import { foundDebugDomain } from './lib/parse-filter/shared';
|
||||
import { createWorker } from './lib/worker';
|
||||
import { endOutputWorkerFarm } from './lib/rules/output-worker-farm';
|
||||
import type { MaybePromise } from './lib/misc';
|
||||
import { RulesetOutput } from './lib/rules/ruleset';
|
||||
import { fetchAssets } from './lib/fetch-assets';
|
||||
@@ -308,8 +307,5 @@ export const buildRejectDomainSet = task(require.main === module, __filename)(as
|
||||
.addFromRuleset(readFileIntoProcessedArray(path.join(SOURCE_DIR, 'non_ip/my_reject.conf')))
|
||||
.write();
|
||||
|
||||
await Promise.all([
|
||||
phishingWorker.end(),
|
||||
endOutputWorkerFarm()
|
||||
]);
|
||||
await phishingWorker.end();
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import path from 'node:path';
|
||||
import { ROOT_DIR } from './constants/dir';
|
||||
import { isCI } from 'ci-info';
|
||||
import { printExternalDownloadStats } from './lib/download-stats';
|
||||
import { endOutputWorkerFarm } from './lib/rules/output-worker-farm';
|
||||
import { endOutputWorkerFarm, warmOutputWorkerFarm } from './lib/rules/output-worker-farm';
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
@@ -85,6 +85,11 @@ const buildFinishedLock = path.join(ROOT_DIR, '.BUILD_FINISHED');
|
||||
require.resolve('./download-mock-assets.worker')
|
||||
)(['downloadMockAssets']);
|
||||
|
||||
// Shared by any task whose FileOutput crosses the offload threshold. Booted here
|
||||
// rather than inside a task so the ~250ms thread spin-up overlaps the downloads
|
||||
// instead of landing on the critical path when the writes finally dispatch.
|
||||
warmOutputWorkerFarm();
|
||||
|
||||
try {
|
||||
// only enable why-is-node-running in GitHub Actions debug mode
|
||||
if (isCI && process.env.RUNNER_DEBUG === '1') {
|
||||
@@ -132,7 +137,6 @@ const buildFinishedLock = path.join(ROOT_DIR, '.BUILD_FINISHED');
|
||||
cdnDownloadWorker.end(),
|
||||
telegramCidrWorker.end(),
|
||||
mockAssetsWorker.end(),
|
||||
// defensive: no-op unless some FileOutput crossed the offload threshold
|
||||
endOutputWorkerFarm()
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@ export function getOutputWorkerFarm(): OutputWorkerFarm {
|
||||
return farm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the farm ahead of time. Spawning the threads costs ~200-300ms (each loads
|
||||
* @swc-node/register and compiles the module graph), and since every big output
|
||||
* dispatches at the very end of a task, that cost otherwise lands entirely on the
|
||||
* critical path. Call this as early as the task starts so it overlaps the downloads.
|
||||
*/
|
||||
export function warmOutputWorkerFarm(): void {
|
||||
getOutputWorkerFarm();
|
||||
}
|
||||
|
||||
export async function endOutputWorkerFarm(): Promise<void> {
|
||||
if (farm) {
|
||||
const f = farm;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { BaseWriteStrategy } from './base';
|
||||
import { appendArrayInPlace } from 'foxts/append-array-in-place';
|
||||
import { noop } from 'foxts/noop';
|
||||
import { withIdentityContent } from '../misc';
|
||||
import stringify from 'json-stringify-pretty-compact';
|
||||
import { OUTPUT_SINGBOX_DIR } from '../../constants/dir';
|
||||
import { MARKER_DOMAIN } from '../../constants/description';
|
||||
import { fastStringArrayJoin } from 'foxts/fast-string-array-join';
|
||||
|
||||
interface SingboxHeadlessRule {
|
||||
domain: string[],
|
||||
@@ -27,13 +27,91 @@ export interface SingboxSourceFormat {
|
||||
rules: SingboxHeadlessRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* json-stringify-pretty-compact spends most of its time re-serializing the whole
|
||||
* subtree at every nesting level just to decide whether it fits on one line -- for
|
||||
* a 145k-domain ruleset that is several full passes over a multi-megabyte string,
|
||||
* plus a final split('\n').
|
||||
*
|
||||
* Our document shape is fixed (`{version, rules: [rule]}`, rule values are flat
|
||||
* arrays of string/number), so we emit the lines directly and only probe as many
|
||||
* items as it takes to know a line cannot fit.
|
||||
*/
|
||||
const SINGBOX_MAX_LENGTH = 120;
|
||||
/** rule values live at indent 6 */
|
||||
const RULE_VALUE_INDENT = 6;
|
||||
|
||||
type SingboxRuleValue = string[] | number[];
|
||||
|
||||
/** prettified JSON of `arr` when it fits `budget`, else null -- bails out early */
|
||||
function inlineIfFits(arr: SingboxRuleValue, budget: number): string | null {
|
||||
if (arr.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
let len = 2; // [ and ]
|
||||
if (len > budget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = JSON.stringify(arr[0]);
|
||||
len += first.length;
|
||||
if (len > budget) {
|
||||
return null;
|
||||
}
|
||||
const parts: string[] = [
|
||||
first
|
||||
];
|
||||
for (let i = 1, l = arr.length; i < l; i++) {
|
||||
const item = JSON.stringify(arr[i]);
|
||||
len += item.length + 2; // ', ' separator
|
||||
if (len > budget) {
|
||||
return null;
|
||||
}
|
||||
parts.push(item);
|
||||
}
|
||||
return '[' + fastStringArrayJoin(parts, ', ') + ']';
|
||||
}
|
||||
|
||||
export function singboxSourceToLines(rule: SingboxHeadlessRule): string[] {
|
||||
const keys = Object.keys(rule) as Array<keyof SingboxHeadlessRule>;
|
||||
const lines: string[] = ['{', ' "version": 2,', ' "rules": [', ' {'];
|
||||
|
||||
for (let i = 0, l = keys.length; i < l; i++) {
|
||||
const key = keys[i];
|
||||
const arr = rule[key];
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new TypeError('singbox rule source value is not an array');
|
||||
}
|
||||
|
||||
const keyPart = JSON.stringify(key) + ': ';
|
||||
const isLast = i === l - 1;
|
||||
const trailing = isLast ? '' : ',';
|
||||
// the library reserves the key prefix plus, unless last, the trailing comma
|
||||
const budget = SINGBOX_MAX_LENGTH - RULE_VALUE_INDENT - (keyPart.length + (isLast ? 0 : 1));
|
||||
|
||||
const inlined = inlineIfFits(arr, budget);
|
||||
if (inlined !== null) {
|
||||
lines.push(' ' + keyPart + inlined + trailing);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(' ' + keyPart + '[');
|
||||
for (let j = 0, l2 = arr.length; j < l2; j++) {
|
||||
lines.push(' ' + JSON.stringify(arr[j]) + (j === l2 - 1 ? '' : ','));
|
||||
}
|
||||
lines.push(' ]' + trailing);
|
||||
}
|
||||
|
||||
lines.push(' }', ' ]', '}');
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class SingboxSource extends BaseWriteStrategy {
|
||||
public readonly name = 'singbox';
|
||||
|
||||
readonly fileExtension = 'json';
|
||||
|
||||
static readonly jsonToLines = (json: unknown): string[] => stringify(json).split('\n');
|
||||
|
||||
// JSON output has no metadata comment at all, nothing to preserve
|
||||
protected override readonly skipCompareOnCI = true;
|
||||
|
||||
@@ -43,10 +121,7 @@ export class SingboxSource extends BaseWriteStrategy {
|
||||
};
|
||||
|
||||
protected get result() {
|
||||
return SingboxSource.jsonToLines({
|
||||
version: 2,
|
||||
rules: [this.singbox]
|
||||
});
|
||||
return singboxSourceToLines(this.singbox);
|
||||
}
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"fnv1a52": "^1.0.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"hntrie": "^1.1.0",
|
||||
"json-stringify-pretty-compact": "4.0.0",
|
||||
"null-prototype-object": "^1.2.7",
|
||||
"picocolors": "^1.1.1",
|
||||
"tar-fs": "^3.1.3",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -56,9 +56,6 @@ importers:
|
||||
hntrie:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
json-stringify-pretty-compact:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
null-prototype-object:
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.7
|
||||
@@ -1609,9 +1606,6 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
json-stringify-pretty-compact@4.0.0:
|
||||
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
|
||||
|
||||
jsonc-eslint-parser@3.1.0:
|
||||
resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
@@ -3468,8 +3462,6 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json-stringify-pretty-compact@4.0.0: {}
|
||||
|
||||
jsonc-eslint-parser@3.1.0:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
Reference in New Issue
Block a user