import http from 'node:http'; import { FAILOVER_PRIMARY_TAG, FAILOVER_RESERVE_TAG, FAILOVER_SELECTOR_TAG, } from '../singbox.js'; type Role = 'primary' | 'reserve'; const SELECTOR_READY_ATTEMPTS = 20; const SELECTOR_READY_DELAY_MS = 100; const transientStartupError = (error: unknown) => ( error && typeof error === 'object' && 'code' in error ? ['ECONNREFUSED', 'ECONNRESET'].includes(String(error.code)) : false ); async function whenReady(operation: () => Promise): Promise { for (let attempt = 1; ; attempt += 1) { try { return await operation(); } catch (error) { if (!transientStartupError(error) || attempt === SELECTOR_READY_ATTEMPTS) throw error; await new Promise((resolve) => setTimeout(resolve, SELECTOR_READY_DELAY_MS)); } } } function request(port: number, method: string, body?: unknown): Promise { return new Promise((resolve, reject) => { const encoded = body === undefined ? null : JSON.stringify(body); const req = http.request({ host: '127.0.0.1', port, path: `/proxies/${encodeURIComponent(FAILOVER_SELECTOR_TAG)}`, method, headers: encoded ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded), } : {}, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { if ((res.statusCode || 500) >= 400) return reject(new Error(`Sing-box selector HTTP ${res.statusCode}`)); if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (cause) { reject(new Error('Sing-box selector вернул невалидный JSON', { cause })); } }); }); req.setTimeout(2_000, () => req.destroy(new Error('Sing-box selector timeout'))); req.on('error', reject); req.end(encoded); }); } const tagFor = (role: Role) => role === 'primary' ? FAILOVER_PRIMARY_TAG : FAILOVER_RESERVE_TAG; const roleFor = (tag: unknown): Role | null => ( tag === FAILOVER_PRIMARY_TAG ? 'primary' : tag === FAILOVER_RESERVE_TAG ? 'reserve' : null ); export function createSingboxSelectorService({ port, send = (method: string, body?: unknown) => request(port, method, body), }: { port: number; send?: (method: string, body?: unknown) => Promise; }) { async function read() { const value = await whenReady(() => send('GET')) as Record; const role = roleFor(value.now); if (!role) throw new Error('Sing-box selector вернул неизвестный outbound'); return { role }; } async function select(role: Role) { await whenReady(() => send('PUT', { name: tagFor(role) })); const selected = await read(); if (selected.role !== role) throw new Error('Sing-box selector не подтвердил переключение'); return selected; } return { read, select }; }