96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
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<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
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<string>();
|
|
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());
|
|
}
|