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 { 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()
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user