Files
flynn/src/gateway/discovery.ts
T
2026-02-16 01:48:59 -08:00

108 lines
2.8 KiB
TypeScript

import { spawn, type ChildProcess } from 'child_process';
export interface GatewayDiscoveryConfig {
serviceName: string;
serviceType: string;
port: number;
txtRecord?: Record<string, string>;
}
export interface GatewayDiscoveryHandle {
stop(): Promise<void>;
}
function toTxtArgs(txtRecord?: Record<string, string>): string[] {
if (!txtRecord) {
return [];
}
return Object.entries(txtRecord).map(([key, value]) => `${key}=${value}`);
}
async function spawnAdvertiser(command: string, args: string[]): Promise<ChildProcess> {
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<void> {
if (child.exitCode !== null || child.killed) {
return;
}
await new Promise<void>((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<GatewayDiscoveryHandle> {
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'}`,
);
}