Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent 0371a8b802
commit af5a4db694
125 changed files with 25018 additions and 1675 deletions
+256 -60
View File
@@ -1,8 +1,46 @@
import type { ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview } from '@/types/models';
import type {
NormalizedProxyRule, ProxyCondition, ProxyProfile, ProxyRouteTrace, ProxyRoutingSettings,
ProxyRule, ProxyRulePreview, ProxyRuleSource,
} from '@/types/models';
import { hashText } from './hash';
function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
const MAX_PAC_BYTES = 4 * 1024 * 1024;
const LARGE_PAC_BYTES = 1024 * 1024;
export interface ProxyCompilationInput {
manualRules: ProxyRule[];
sources: ProxyRuleSource[];
sourceRules: Map<string, NormalizedProxyRule[]>;
profiles: ProxyProfile[];
routing: ProxyRoutingSettings;
}
export interface CompiledProxyArtifact {
revision: string;
pacScript: string;
compiledBytes: number;
manualRuleCount: number;
sourceRuleCount: number;
warnings: string[];
}
type TrieNode = { $?: number; [key: string]: TrieNode | number | undefined };
function json(value: unknown): string {
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
}
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
return [...rules].sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
}
export function sortedProxyRuleSources(sources: ProxyRuleSource[]): ProxyRuleSource[] {
return [...sources].sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
}
export function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
if (profile.kind === 'direct') return 'DIRECT';
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能嵌套到规则 PAC 中`);
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能作为自动切换出口`);
const host = profile.host || '127.0.0.1';
const port = profile.port || 8083;
const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}`
@@ -11,82 +49,240 @@ function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['fai
return failMode === 'open' ? `${proxy}; DIRECT` : proxy;
}
function pacLiteral(value: string): string {
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
function wildcardRegex(pattern: string): string {
return `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`;
}
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
return [...rules].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
}
function pacCondition(rawPattern: string): string {
const pattern = rawPattern.trim();
if (!pattern) return '';
if (pattern.includes('://') || pattern.includes('/')) return `shExpMatch(url, ${pacLiteral(pattern)})`;
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2);
return `(host === ${pacLiteral(domain)} || dnsDomainIs(host, ${pacLiteral(`.${domain}`)}))`;
function addTrieCondition(trie: TrieNode, condition: ProxyCondition): boolean {
if (condition.type !== 'host_exact' && condition.type !== 'host_suffix') return false;
const labels = condition.value.toLowerCase().replace(/\.$/, '').split('.').filter(Boolean).reverse();
if (labels.length === 0) return false;
let node = trie;
for (const label of labels) {
const key = `:${label}`;
const existing = node[key];
if (!existing || typeof existing === 'number') node[key] = {};
node = node[key] as TrieNode;
}
if (pattern.includes('*') || pattern.includes('?')) return `shExpMatch(host, ${pacLiteral(pattern)})`;
return `(host === ${pacLiteral(pattern)} || dnsDomainIs(host, ${pacLiteral(`.${pattern}`)}))`;
node.$ = (node.$ || 0) | (condition.type === 'host_suffix' ? 1 : 2);
return true;
}
export function compileProxyRules(
rules: ProxyRule[],
interface ConditionCompiler {
regexps: Array<{ value: string; flags: string }>;
}
function compileCondition(condition: ProxyCondition, compiler: ConditionCompiler): string {
const value = condition.value.trim();
if (condition.type === 'host_exact') return `host===${json(value.toLowerCase())}`;
if (condition.type === 'host_suffix') {
const domain = value.toLowerCase();
return `(host===${json(domain)}||dnsDomainIs(host,${json(`.${domain}`)}))`;
}
if (condition.type === 'url_prefix') return `url.indexOf(${json(value)})===0`;
if (condition.type === 'keyword') return `url.indexOf(${json(value)})>=0`;
const expression = condition.type === 'host_wildcard' || condition.type === 'url_wildcard'
? wildcardRegex(value)
: value;
try {
new RegExp(expression, 'i');
} catch {
throw new Error(`无效的${condition.type.startsWith('host_') ? '域名' : 'URL'}正则表达式:${value.slice(0, 160)}`);
}
const index = compiler.regexps.push({ value: expression, flags: 'i' }) - 1;
return `__rx[${index}].test(${condition.type.startsWith('host_') ? 'host' : 'url'})`;
}
function resolveProfile(
profileIdOrName: string | undefined,
profiles: ProxyProfile[],
routing: ProxyRoutingSettings = { defaultProfileId: 'direct', failMode: 'closed' },
): string {
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
const branches = sortedProxyRules(rules)
.filter((rule) => rule.enabled && rule.patterns.length > 0)
.flatMap((rule) => {
const profile = profileMap.get(rule.proxyProfileId);
if (!profile) return [];
const conditions = rule.patterns.map(pacCondition).filter(Boolean);
return conditions.length > 0 ? [` // ${rule.name} [priority=${rule.priority}]\n if (${conditions.join(' || ')}) return ${pacLiteral(profileToPac(profile, routing.failMode))};`] : [];
fallbackId: string,
): ProxyProfile {
const profile = profiles.find((item) => item.id === profileIdOrName || item.name === profileIdOrName)
|| profiles.find((item) => item.id === fallbackId)
|| profiles.find((item) => item.id === 'direct');
if (!profile || !['direct', 'fixed_servers'].includes(profile.kind)) throw new Error(`自动切换出口不存在:${profileIdOrName || fallbackId}`);
return profile;
}
function resolveNamedSourceProfile(name: string, source: ProxyRuleSource, profiles: ProxyProfile[]): ProxyProfile {
const profile = profiles.find((item) => item.id === name || item.name === name);
if (!profile || !['direct', 'fixed_servers'].includes(profile.kind)) {
throw new Error(`规则源“${source.name}”引用了未知或不可路由的出口:${name}`);
}
return profile;
}
export function proxyCompilationRevision(input: ProxyCompilationInput): string {
return hashText(JSON.stringify({
manual: sortedProxyRules(input.manualRules).map((rule) => [
rule.id, rule.enabled, rule.order, rule.condition.type, rule.condition.value, rule.proxyProfileId, rule.updatedAt,
]),
sources: sortedProxyRuleSources(input.sources).map((source) => [
source.id, source.enabled, source.order, source.revision, source.matchProfileId, source.bypassProfileId,
]),
profiles: input.profiles.map((profile) => [
profile.id, profile.kind, profile.scheme, profile.host, profile.port, profile.bypass, profile.authEnabled,
]),
routing: input.routing,
}));
}
function sourceBlock(
source: ProxyRuleSource,
rules: NormalizedProxyRule[],
input: ProxyCompilationInput,
compiler: ConditionCompiler,
tries: TrieNode[],
): string[] {
if (rules.length === 0) return [];
const hasCustomResults = rules.some((rule) => rule.resultProfileName);
if (hasCustomResults) {
return rules.map((rule) => {
const target = rule.exception
? resolveProfile(source.bypassProfileId, input.profiles, input.routing.defaultProfileId)
: rule.resultProfileName
? resolveNamedSourceProfile(rule.resultProfileName, source, input.profiles)
: resolveProfile(source.matchProfileId, input.profiles, source.matchProfileId);
return `if(${compileCondition(rule.condition, compiler)})return ${json(profileToPac(target, input.routing.failMode))};`;
});
const fallback = profileMap.get(routing.defaultProfileId) || profileMap.get('direct');
return `function FindProxyForURL(url, host) {\n${branches.join('\n')}\n return ${pacLiteral(fallback ? profileToPac(fallback, routing.failMode) : 'DIRECT')};\n}`;
}
const blocks: string[] = [];
for (const exception of [true, false]) {
const selected = rules.filter((rule) => rule.exception === exception);
if (selected.length === 0) continue;
const trie: TrieNode = {};
const slow: string[] = [];
for (const rule of selected) {
if (!addTrieCondition(trie, rule.condition)) slow.push(compileCondition(rule.condition, compiler));
}
const conditions: string[] = [];
if (Object.keys(trie).length > 0) {
const trieIndex = tries.push(trie) - 1;
conditions.push(`__matchHost(host,__tries[${trieIndex}])`);
}
conditions.push(...slow);
if (conditions.length === 0) continue;
const target = resolveProfile(
exception ? source.bypassProfileId : source.matchProfileId,
input.profiles,
input.routing.defaultProfileId,
);
const result = json(profileToPac(target, input.routing.failMode));
const chunkSize = 64;
for (let index = 0; index < conditions.length; index += chunkSize) {
blocks.push(`if(${conditions.slice(index, index + chunkSize).join('||')})return ${result};`);
}
}
return blocks;
}
function wildcardRegexp(pattern: string): RegExp {
return new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`, 'i');
export function compileProxyRules(input: ProxyCompilationInput): CompiledProxyArtifact {
const compiler: ConditionCompiler = { regexps: [] };
const tries: TrieNode[] = [];
const body: string[] = [];
const enabledManualRules = sortedProxyRules(input.manualRules).filter((rule) => rule.enabled);
for (const rule of enabledManualRules) {
const target = resolveProfile(rule.proxyProfileId, input.profiles, input.routing.defaultProfileId);
body.push(`if(${compileCondition(rule.condition, compiler)})return ${json(profileToPac(target, input.routing.failMode))};`);
}
let sourceRuleCount = 0;
for (const source of sortedProxyRuleSources(input.sources).filter((item) => item.enabled && item.revision)) {
const rules = input.sourceRules.get(source.id) || [];
sourceRuleCount += rules.length;
body.push(...sourceBlock(source, rules, input, compiler, tries));
}
const fallback = resolveProfile(input.routing.defaultProfileId, input.profiles, 'direct');
const regexps = `[${compiler.regexps.map((item) => `new RegExp(${json(item.value)},${json(item.flags)})`).join(',')}]`;
const pacScript = `var __rx=${regexps},__tries=${json(tries)};function __matchHost(host,trie){var parts=host.toLowerCase().split('.'),node=trie;for(var i=parts.length-1;i>=0;i--){node=node[':'+parts[i]];if(!node)return false;if((node.$||0)&1)return true}return !!((node.$||0)&2)}function FindProxyForURL(url,host){${body.join('')}return ${json(profileToPac(fallback, input.routing.failMode))};}`;
const compiledBytes = new TextEncoder().encode(pacScript).byteLength;
if (compiledBytes > MAX_PAC_BYTES) {
throw new Error(`编译后的 PAC 为 ${(compiledBytes / 1024 / 1024).toFixed(2)} MB,超过 4 MB 安全上限;请停用重叠规则源或拆分自动切换方案`);
}
const warnings: string[] = [];
if (compiledBytes > LARGE_PAC_BYTES) warnings.push(`PAC 已达到 ${(compiledBytes / 1024 / 1024).toFixed(2)} MB,请关注首次应用耗时`);
if (compiler.regexps.length > 1_000) warnings.push(`${compiler.regexps.length} 条规则进入正则慢速路径`);
return {
revision: proxyCompilationRevision(input),
pacScript,
compiledBytes,
manualRuleCount: enabledManualRules.length,
sourceRuleCount,
warnings,
};
}
export function proxyPatternMatches(rawPattern: string, rawUrl: string): boolean {
export function proxyConditionMatches(condition: ProxyCondition, rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
const pattern = rawPattern.trim();
if (!pattern) return false;
if (pattern.includes('://') || pattern.includes('/')) return wildcardRegexp(pattern).test(rawUrl);
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2).toLowerCase();
return url.hostname.toLowerCase() === domain || url.hostname.toLowerCase().endsWith(`.${domain}`);
const host = url.hostname.toLowerCase();
const value = condition.value.trim();
if (condition.type === 'host_exact') return host === value.toLowerCase();
if (condition.type === 'host_suffix') {
const domain = value.toLowerCase();
return host === domain || host.endsWith(`.${domain}`);
}
if (pattern.includes('*') || pattern.includes('?')) return wildcardRegexp(pattern).test(url.hostname);
return url.hostname.toLowerCase() === pattern.toLowerCase() || url.hostname.toLowerCase().endsWith(`.${pattern.toLowerCase()}`);
if (condition.type === 'url_prefix') return rawUrl.startsWith(value);
if (condition.type === 'keyword') return rawUrl.includes(value);
const regexp = new RegExp(
condition.type === 'host_wildcard' || condition.type === 'url_wildcard' ? wildcardRegex(value) : value,
'i',
);
return regexp.test(condition.type.startsWith('host_') ? host : rawUrl);
} catch {
return false;
}
}
export function previewProxyRules(
url: string,
rules: ProxyRule[],
profiles: ProxyProfile[],
routing: ProxyRoutingSettings,
): ProxyRulePreview {
const matches = sortedProxyRules(rules).filter((rule) => rule.enabled && rule.patterns.some((pattern) => proxyPatternMatches(pattern, url)));
const profileIds = [...new Set(matches.map((rule) => rule.proxyProfileId))];
const effectiveProfileId = matches[0]?.proxyProfileId || routing.defaultProfileId;
const profile = profiles.find((item) => item.id === effectiveProfileId) || profiles.find((item) => item.id === 'direct')!;
export function previewProxyRules(url: string, input: ProxyCompilationInput): ProxyRulePreview {
const parsed = new URL(url);
const trace: ProxyRouteTrace[] = [];
for (const rule of sortedProxyRules(input.manualRules).filter((item) => item.enabled)) {
const matched = proxyConditionMatches(rule.condition, url);
if (trace.length < 12) trace.push({
kind: 'manual', name: rule.name, condition: `${rule.condition.type}: ${rule.condition.value}`,
matched, profileId: rule.proxyProfileId,
});
if (matched) {
const profile = resolveProfile(rule.proxyProfileId, input.profiles, input.routing.defaultProfileId);
return {
url, hostname: parsed.hostname, effectiveProfileId: profile.id, effectiveProxy: profileToPac(profile, input.routing.failMode),
matchedKind: 'manual', matchedName: rule.name, matchedCondition: rule.condition.value, matchedRuleId: rule.id, trace,
};
}
}
for (const source of sortedProxyRuleSources(input.sources).filter((item) => item.enabled && item.revision)) {
const rules = input.sourceRules.get(source.id) || [];
const hasCustomResults = rules.some((rule) => rule.resultProfileName);
const previewOrder = hasCustomResults
? rules
: [...rules.filter((rule) => rule.exception), ...rules.filter((rule) => !rule.exception)];
const matchedRule = previewOrder.find((rule) => proxyConditionMatches(rule.condition, url));
trace.push({
kind: 'source', name: source.name, condition: matchedRule?.condition.value, matched: Boolean(matchedRule),
profileId: matchedRule?.exception ? source.bypassProfileId : source.matchProfileId,
});
if (matchedRule) {
const profile = resolveProfile(
matchedRule.exception ? source.bypassProfileId : source.matchProfileId,
input.profiles, input.routing.defaultProfileId,
);
const effectiveProfile = !matchedRule.exception && matchedRule.resultProfileName
? resolveNamedSourceProfile(matchedRule.resultProfileName, source, input.profiles)
: profile;
return {
url, hostname: parsed.hostname, effectiveProfileId: effectiveProfile.id, effectiveProxy: profileToPac(effectiveProfile, input.routing.failMode),
matchedKind: 'source', matchedName: source.name, matchedCondition: matchedRule.raw, matchedSourceId: source.id, trace,
};
}
}
const profile = resolveProfile(input.routing.defaultProfileId, input.profiles, 'direct');
trace.push({ kind: 'default', name: '默认出口', matched: true, profileId: profile.id });
return {
url,
matchedRuleIds: matches.map((rule) => rule.id),
effectiveRuleId: matches[0]?.id,
effectiveProfileId: profile.id,
effectiveProxy: profileToPac(profile, routing.failMode),
conflict: profileIds.length > 1,
conflictProfileIds: profileIds,
url, hostname: parsed.hostname, effectiveProfileId: profile.id, effectiveProxy: profileToPac(profile, input.routing.failMode),
matchedKind: 'default', matchedName: '默认出口', trace,
};
}