feat(gateway): add optional bonjour/mdns discovery
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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'}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user