mirror of
https://github.com/SukkaW/Surge.git
synced 2026-09-12 18:44:36 +08:00
180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
import { BaseWriteStrategy } from './base';
|
|
import { appendArrayInPlace } from 'foxts/append-array-in-place';
|
|
import { noop } from 'foxts/noop';
|
|
import { withIdentityContent } from '../misc';
|
|
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[],
|
|
domain_suffix: string[],
|
|
domain_keyword?: string[],
|
|
domain_regex?: string[],
|
|
source_ip_cidr?: string[],
|
|
ip_cidr?: string[],
|
|
source_port?: number[],
|
|
source_port_range?: string[],
|
|
port?: number[],
|
|
port_range?: string[],
|
|
process_name?: string[],
|
|
process_path?: string[],
|
|
network?: string[]
|
|
}
|
|
|
|
export interface SingboxSourceFormat {
|
|
version: 2 | number & {},
|
|
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';
|
|
|
|
// JSON output has no metadata comment at all, nothing to preserve
|
|
protected override readonly skipCompareOnCI = true;
|
|
|
|
private readonly singbox: SingboxHeadlessRule = {
|
|
domain: [MARKER_DOMAIN],
|
|
domain_suffix: [MARKER_DOMAIN]
|
|
};
|
|
|
|
protected get result() {
|
|
return singboxSourceToLines(this.singbox);
|
|
}
|
|
|
|
constructor(
|
|
/** Since sing-box only have one format that does not reflect type, we need to specify it */
|
|
public type: 'domainset' | 'non_ip' | 'ip' /* | (string & {}) */,
|
|
public readonly outputDir = OUTPUT_SINGBOX_DIR
|
|
) {
|
|
super(outputDir);
|
|
}
|
|
|
|
withPadding = withIdentityContent;
|
|
|
|
writeDomain(domain: string): void {
|
|
this.singbox.domain.push(domain);
|
|
}
|
|
|
|
writeDomainSuffix(domain: string): void {
|
|
this.singbox.domain_suffix.push(domain);
|
|
}
|
|
|
|
writeDomainKeywords(keyword: Set<string>): void {
|
|
appendArrayInPlace(
|
|
this.singbox.domain_keyword ??= [],
|
|
Array.from(keyword)
|
|
);
|
|
}
|
|
|
|
writeDomainWildcard = noop;
|
|
writeUserAgents = noop;
|
|
writeProcessNames = noop;
|
|
writeProcessPaths = noop;
|
|
writeUrlRegexes = noop;
|
|
|
|
writeIpCidrs(ipCidr: string[]): void {
|
|
appendArrayInPlace(
|
|
this.singbox.ip_cidr ??= [],
|
|
ipCidr
|
|
);
|
|
}
|
|
|
|
writeIpCidr6s(ipCidr6: string[]): void {
|
|
appendArrayInPlace(
|
|
this.singbox.ip_cidr ??= [],
|
|
ipCidr6
|
|
);
|
|
}
|
|
|
|
writeGeoip = noop;
|
|
writeIpAsns = noop;
|
|
writeSourceIpCidrs = noop;
|
|
writeSourcePorts = noop;
|
|
writeDestinationPorts = noop;
|
|
writeProtocols = noop;
|
|
writeOtherRules = noop;
|
|
}
|