From 3f4449b334d6a0253697bfa961d2c40fff2753aa Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 2 Aug 2026 01:24:28 +0800 Subject: [PATCH] Perf: fast sing-box JSON print --- Build/build-reject-domainset.ts | 6 +- Build/index.ts | 8 ++- Build/lib/rules/output-worker-farm.ts | 10 +++ Build/lib/writing-strategy/singbox.ts | 89 ++++++++++++++++++++++++--- package.json | 1 - pnpm-lock.yaml | 8 --- 6 files changed, 99 insertions(+), 23 deletions(-) diff --git a/Build/build-reject-domainset.ts b/Build/build-reject-domainset.ts index cc493631..75ebb2fc 100644 --- a/Build/build-reject-domainset.ts +++ b/Build/build-reject-domainset.ts @@ -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(); }); diff --git a/Build/index.ts b/Build/index.ts index f53f4200..e75f13f1 100644 --- a/Build/index.ts +++ b/Build/index.ts @@ -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() ]); diff --git a/Build/lib/rules/output-worker-farm.ts b/Build/lib/rules/output-worker-farm.ts index de9ba421..8fd28594 100644 --- a/Build/lib/rules/output-worker-farm.ts +++ b/Build/lib/rules/output-worker-farm.ts @@ -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 { if (farm) { const f = farm; diff --git a/Build/lib/writing-strategy/singbox.ts b/Build/lib/writing-strategy/singbox.ts index 7acb947a..16315bae 100644 --- a/Build/lib/writing-strategy/singbox.ts +++ b/Build/lib/writing-strategy/singbox.ts @@ -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; + 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( diff --git a/package.json b/package.json index 841d96ec..049528cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76a2477d..12393181 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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