export const INITIAL_ROUTE_RULES = Object.freeze([ Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }), ]); const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']); const RULE_OUTBOUNDS = new Set(['vpn', 'direct']); export const ROUTE_RULES_CONTRACT_VERSION = 2; export const MAX_ROUTE_RULES = 200; export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword'; export type RouteRuleOutbound = 'vpn' | 'direct'; export interface NormalizedRouteRule { type: RouteRuleType; value: string; enabled: boolean; outbound: RouteRuleOutbound; } function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function hostname(value: unknown) { const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, ''); if (!input) throw new TypeError('Domain rule value is required'); const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`); const normalized = url.hostname.replace(/\.$/, '').toLowerCase(); if (!normalized || normalized.length > 253) throw new TypeError('Invalid domain rule value'); return normalized; } function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule { const rule = record(input); const type = String(rule.type || '').trim(); if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type'); if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') { throw new TypeError('Invalid domain rule enabled state'); } if (strict && !Object.hasOwn(rule, 'outbound')) { throw new TypeError('Route rule outbound is required'); } const outbound = Object.hasOwn(rule, 'outbound') ? String(rule.outbound || '').trim() : 'direct'; if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound'); const value = type === 'domain_keyword' ? String(rule.value || '').trim().toLowerCase() : hostname(rule.value); if (!value || value.length > 253 || /[\s/:?#]/.test(value)) { throw new TypeError('Invalid domain rule value'); } return { type: type as RouteRuleType, value, enabled: rule.enabled !== false, outbound: outbound as RouteRuleOutbound, }; } export function normalizeRouteRules( value: unknown, { strict = false }: { strict?: boolean } = {}, ): NormalizedRouteRule[] { if (!Array.isArray(value)) { if (strict) throw new TypeError('Route rules must be an array'); return []; } if (strict && value.length > MAX_ROUTE_RULES) { throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`); } const seen = new Set(); const normalized: NormalizedRouteRule[] = []; for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { try { const rule = normalizeRule(candidate, strict); const key = `${rule.type}:${rule.value}`; if (seen.has(key)) continue; seen.add(key); normalized.push(rule); } catch (error) { if (strict) throw error; } } return normalized; } export function canAppendRouteRule(rules: unknown) { return Array.isArray(rules) && rules.length < MAX_ROUTE_RULES && rules.every((rule) => String(record(rule).value || '').trim()); }