import { spawn, type ChildProcess } from 'child_process'; export interface GatewayDiscoveryConfig { serviceName: string; serviceType: string; port: number; txtRecord?: Record; } export interface GatewayDiscoveryHandle { stop(): Promise; } function toTxtArgs(txtRecord?: Record): string[] { if (!txtRecord) { return []; } return Object.entries(txtRecord).map(([key, value]) => `${key}=${value}`); } async function spawnAdvertiser(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: 'ignore' }); const onError = (error: Error): void => { cleanup(); reject(error); }; const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { cleanup(); reject(new Error(`${command} exited early (code=${code ?? 'null'}, signal=${signal ?? 'null'})`)); }; const onSpawn = (): void => { setTimeout(() => { cleanup(); resolve(child); }, 100); }; const cleanup = (): void => { child.off('error', onError); child.off('exit', onExit); child.off('spawn', onSpawn); }; child.once('error', onError); child.once('exit', onExit); child.once('spawn', onSpawn); }); } async function stopChild(child: ChildProcess): Promise { if (child.exitCode !== null || child.killed) { return; } await new Promise((resolve) => { const timeout = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1000); child.once('exit', () => { clearTimeout(timeout); resolve(); }); child.kill('SIGTERM'); }); } /** * Starts LAN discovery using best-effort host tools. * Priority: avahi-publish-service (Linux) -> dns-sd (macOS). */ export async function startGatewayDiscovery(config: GatewayDiscoveryConfig): Promise { const txtArgs = toTxtArgs(config.txtRecord); const attempts: Array<{ command: string; args: string[] }> = [ { command: 'avahi-publish-service', args: [config.serviceName, config.serviceType, String(config.port), ...txtArgs], }, { command: 'dns-sd', args: ['-R', config.serviceName, config.serviceType, 'local', String(config.port), ...txtArgs], }, ]; let lastError: Error | null = null; for (const attempt of attempts) { try { const child = await spawnAdvertiser(attempt.command, attempt.args); child.unref(); return { stop: async () => { await stopChild(child); }, }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } throw new Error( `Failed to start mDNS advertiser (tried avahi-publish-service and dns-sd): ${lastError?.message ?? 'unknown error'}`, ); }