Perf: fast sing-box JSON print

This commit is contained in:
SukkaW
2026-08-02 01:24:28 +08:00
parent fa9c02d27d
commit 3f4449b334
6 changed files with 99 additions and 23 deletions

View File

@@ -19,7 +19,6 @@ import { OUTPUT_INTERNAL_DIR, SOURCE_DIR } from './constants/dir';
import { DomainsetOutput, AdGuardHomeOutput } from './lib/rules/domainset'; import { DomainsetOutput, AdGuardHomeOutput } from './lib/rules/domainset';
import { foundDebugDomain } from './lib/parse-filter/shared'; import { foundDebugDomain } from './lib/parse-filter/shared';
import { createWorker } from './lib/worker'; import { createWorker } from './lib/worker';
import { endOutputWorkerFarm } from './lib/rules/output-worker-farm';
import type { MaybePromise } from './lib/misc'; import type { MaybePromise } from './lib/misc';
import { RulesetOutput } from './lib/rules/ruleset'; import { RulesetOutput } from './lib/rules/ruleset';
import { fetchAssets } from './lib/fetch-assets'; 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'))) .addFromRuleset(readFileIntoProcessedArray(path.join(SOURCE_DIR, 'non_ip/my_reject.conf')))
.write(); .write();
await Promise.all([ await phishingWorker.end();
phishingWorker.end(),
endOutputWorkerFarm()
]);
}); });

View File

@@ -28,7 +28,7 @@ import path from 'node:path';
import { ROOT_DIR } from './constants/dir'; import { ROOT_DIR } from './constants/dir';
import { isCI } from 'ci-info'; import { isCI } from 'ci-info';
import { printExternalDownloadStats } from './lib/download-stats'; 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) => { process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error); console.error('Uncaught exception:', error);
@@ -85,6 +85,11 @@ const buildFinishedLock = path.join(ROOT_DIR, '.BUILD_FINISHED');
require.resolve('./download-mock-assets.worker') require.resolve('./download-mock-assets.worker')
)(['downloadMockAssets']); )(['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 { try {
// only enable why-is-node-running in GitHub Actions debug mode // only enable why-is-node-running in GitHub Actions debug mode
if (isCI && process.env.RUNNER_DEBUG === '1') { if (isCI && process.env.RUNNER_DEBUG === '1') {
@@ -132,7 +137,6 @@ const buildFinishedLock = path.join(ROOT_DIR, '.BUILD_FINISHED');
cdnDownloadWorker.end(), cdnDownloadWorker.end(),
telegramCidrWorker.end(), telegramCidrWorker.end(),
mockAssetsWorker.end(), mockAssetsWorker.end(),
// defensive: no-op unless some FileOutput crossed the offload threshold
endOutputWorkerFarm() endOutputWorkerFarm()
]); ]);

View File

@@ -21,6 +21,16 @@ export function getOutputWorkerFarm(): OutputWorkerFarm {
return farm; 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> { export async function endOutputWorkerFarm(): Promise<void> {
if (farm) { if (farm) {
const f = farm; const f = farm;

View File

@@ -2,9 +2,9 @@ import { BaseWriteStrategy } from './base';
import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { appendArrayInPlace } from 'foxts/append-array-in-place';
import { noop } from 'foxts/noop'; import { noop } from 'foxts/noop';
import { withIdentityContent } from '../misc'; import { withIdentityContent } from '../misc';
import stringify from 'json-stringify-pretty-compact';
import { OUTPUT_SINGBOX_DIR } from '../../constants/dir'; import { OUTPUT_SINGBOX_DIR } from '../../constants/dir';
import { MARKER_DOMAIN } from '../../constants/description'; import { MARKER_DOMAIN } from '../../constants/description';
import { fastStringArrayJoin } from 'foxts/fast-string-array-join';
interface SingboxHeadlessRule { interface SingboxHeadlessRule {
domain: string[], domain: string[],
@@ -27,13 +27,91 @@ export interface SingboxSourceFormat {
rules: SingboxHeadlessRule[] 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 { export class SingboxSource extends BaseWriteStrategy {
public readonly name = 'singbox'; public readonly name = 'singbox';
readonly fileExtension = 'json'; readonly fileExtension = 'json';
static readonly jsonToLines = (json: unknown): string[] => stringify(json).split('\n');
// JSON output has no metadata comment at all, nothing to preserve // JSON output has no metadata comment at all, nothing to preserve
protected override readonly skipCompareOnCI = true; protected override readonly skipCompareOnCI = true;
@@ -43,10 +121,7 @@ export class SingboxSource extends BaseWriteStrategy {
}; };
protected get result() { protected get result() {
return SingboxSource.jsonToLines({ return singboxSourceToLines(this.singbox);
version: 2,
rules: [this.singbox]
});
} }
constructor( constructor(

View File

@@ -34,7 +34,6 @@
"fnv1a52": "^1.0.0", "fnv1a52": "^1.0.0",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"hntrie": "^1.1.0", "hntrie": "^1.1.0",
"json-stringify-pretty-compact": "4.0.0",
"null-prototype-object": "^1.2.7", "null-prototype-object": "^1.2.7",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"tar-fs": "^3.1.3", "tar-fs": "^3.1.3",

8
pnpm-lock.yaml generated
View File

@@ -56,9 +56,6 @@ importers:
hntrie: hntrie:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
json-stringify-pretty-compact:
specifier: 4.0.0
version: 4.0.0
null-prototype-object: null-prototype-object:
specifier: ^1.2.7 specifier: ^1.2.7
version: 1.2.7 version: 1.2.7
@@ -1609,9 +1606,6 @@ packages:
json-stable-stringify-without-jsonify@1.0.1: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} 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: jsonc-eslint-parser@3.1.0:
resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -3468,8 +3462,6 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-pretty-compact@4.0.0: {}
jsonc-eslint-parser@3.1.0: jsonc-eslint-parser@3.1.0:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0