108 lines
2.8 KiB
TypeScript
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'}`,
|
|
);
|
|
}
|