86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
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<T>(operation: () => Promise<T>): Promise<T> {
|
|
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<unknown> {
|
|
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<unknown>;
|
|
}) {
|
|
async function read() {
|
|
const value = await whenReady(() => send('GET')) as Record<string, unknown>;
|
|
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 };
|
|
}
|