import type { NormalizedProxyRule, ProxyCondition, ProxyProfile, ProxyRouteTrace, ProxyRoutingSettings, ProxyRule, ProxyRulePreview, ProxyRuleSource, } from '@/types/models'; import { hashText } from './hash'; const MAX_PAC_BYTES = 4 * 1024 * 1024; const LARGE_PAC_BYTES = 1024 * 1024; export interface ProxyCompilationInput { manualRules: ProxyRule[]; sources: ProxyRuleSource[]; sourceRules: Map; 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} 不能作为自动切换出口`); const host = profile.host || '127.0.0.1'; const port = profile.port || 8083; const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}` : profile.scheme === 'socks5' ? `SOCKS5 ${host}:${port}` : profile.scheme === 'https' ? `HTTPS ${host}:${port}` : `PROXY ${host}:${port}`; return failMode === 'open' ? `${proxy}; DIRECT` : proxy; } function wildcardRegex(pattern: string): string { return `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`; } 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; } node.$ = (node.$ || 0) | (condition.type === 'host_suffix' ? 1 : 2); return true; } 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[], 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 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; } 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 proxyConditionMatches(condition: ProxyCondition, rawUrl: string): boolean { try { const url = new URL(rawUrl); 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 (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, 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, hostname: parsed.hostname, effectiveProfileId: profile.id, effectiveProxy: profileToPac(profile, input.routing.failMode), matchedKind: 'default', matchedName: '默认出口', trace, }; }