// One-command dev stack: boot a mock server (ephemeral port by default) and // spawn the client dev command pointed at it, with tied lifetimes — so // several server+client pairs can run on one machine without hand-assigning // ports or orphaning processes. import { spawn, type ChildProcess } from 'node:child_process'; import { createMockServer, type MockServer } from './server.ts'; import type { MockServerConfig } from './types.ts'; export interface LaunchStackOptions { config: MockServerConfig; // The client dev command, spawned once the server port is known. An array // is spawned directly ([command, ...args]); a string runs through the // shell. Inherits stdio so the client's output streams to the terminal. clientCommand: string | string[]; // Server port; default 0 lets the OS pick a free one (the parallel case). port?: number; cwd?: string; // Extra env for the client, derived from the bound port. Merged over // process.env and the default { API_HOST: `ws://localhost:${port}` }. // Use it to also pin per-instance client ports, e.g. // (port) => ({ PORT: String(port + 1) }) — or leave the client dev // server's own auto-increment (vite's default) to handle collisions. clientEnv?: (port: number) => Record; // Default true: stop the mock server when the client exits, and terminate // the client on SIGINT/SIGTERM — one Ctrl+C tears the whole pair down. tieLifetimes?: boolean; // How long the client tree gets to exit on SIGINT before it is SIGKILLed. // Defaults to CLIENT_EXIT_GRACE_MS; lower it in tests, or raise it for a // client that needs longer to flush. clientExitGraceMs?: number; } export interface LaunchedStack { server: MockServer; port: number; client: ChildProcess; // Resolves with the client's exit code (null if killed by signal). When // tieLifetimes is on, the server has been stopped by the time it resolves. clientExited: Promise; // Terminate the client and stop the server. stop(): Promise; } // How long a client gets to exit on SIGINT before it is killed outright. A dev // server that ignores the signal must not keep its port (or hang stop()). const CLIENT_EXIT_GRACE_MS = 5000; // How often the watchdog checks whether the launcher is still alive. const WATCHDOG_POLL_MS = 500; // Signals the client tree, not just the direct child. The client command is // normally a launcher itself (genx -> vite -> esbuild), so signalling only the // immediate pid can leave grandchildren holding the port. `detached: true` // makes the child a process-group leader, and a negative pid addresses that // whole group. function killClientTree(client: ChildProcess, signal: NodeJS.Signals): void { if (client.pid === undefined || client.exitCode !== null || client.signalCode !== null) return; try { process.kill(-client.pid, signal); } catch { // No process group (Windows, or the group is already gone) — fall back to // the single child. try { client.kill(signal); } catch { // Already exited between the check and the signal; nothing to do. } } } // Nothing in THIS process runs when it is SIGKILLed or dies with the terminal, // so parent-death cleanup cannot live here: the client would be reparented to // init and keep serving its port forever (verified — a hard-killed launcher // leaves vite listening). This tiny detached watcher polls for the launcher // and takes the client's process group down with it. It also exits as soon as // the client is gone, so it can never outlive the pair it is watching. const WATCHDOG_SOURCE = ` const [launcher, target] = process.argv.slice(1).map(Number); const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }; const stop = (sig) => { try { process.kill(-target, sig); } catch { try { process.kill(target, sig); } catch {} } }; const timer = setInterval(() => { if (!alive(target)) { clearInterval(timer); process.exit(0); } if (alive(launcher)) return; clearInterval(timer); stop('SIGTERM'); setTimeout(() => { stop('SIGKILL'); process.exit(0); }, ${CLIENT_EXIT_GRACE_MS}); }, ${WATCHDOG_POLL_MS}); `; function startWatchdog(clientPid: number): ChildProcess | undefined { try { const watchdog = spawn( process.execPath, ['-e', WATCHDOG_SOURCE, String(process.pid), String(clientPid)], { detached: true, stdio: 'ignore' }, ); // Detached + unref'd so it survives a hard kill of this process and never // holds this process's event loop open. watchdog.unref(); return watchdog; } catch (error) { console.warn('[mock-server] Could not start the client watchdog:', error); return undefined; } } export async function launchStack(options: LaunchStackOptions): Promise { const { config, clientCommand, port = 0, cwd, clientEnv, tieLifetimes = true, clientExitGraceMs = CLIENT_EXIT_GRACE_MS, } = options; const server = createMockServer(config); const boundPort = await server.listen(port); const env = { ...process.env, API_HOST: `ws://localhost:${boundPort}`, ...clientEnv?.(boundPort), }; // detached: the client leads its own process group so the whole tree can be // signalled at once. It also means a terminal Ctrl+C no longer reaches the // client directly — it reaches this process, whose SIGINT handler below // tears the group down, so shutdown always takes one controlled path. const client = Array.isArray(clientCommand) ? spawn(clientCommand[0], clientCommand.slice(1), { cwd, env, stdio: 'inherit', detached: true, }) : spawn(clientCommand, { cwd, env, stdio: 'inherit', shell: true, detached: true }); const watchdog = tieLifetimes && client.pid !== undefined ? startWatchdog(client.pid) : undefined; function cleanupWatchdog(): void { if (watchdog?.pid === undefined) return; try { process.kill(watchdog.pid, 'SIGKILL'); } catch { // Already exited on noticing the client was gone. } } // Resolves once the client tree is actually down, so stop() means everything // is stopped rather than merely signalled. function stopClient(): Promise { if (client.pid === undefined || client.exitCode !== null || client.signalCode !== null) { return Promise.resolve(); } return new Promise((resolve) => { const escalate = setTimeout(() => killClientTree(client, 'SIGKILL'), clientExitGraceMs); client.once('exit', () => { clearTimeout(escalate); resolve(); }); killClientTree(client, 'SIGINT'); }); } let stopping = false; async function stop(): Promise { if (stopping) return; stopping = true; removeSignalHandlers(); cleanupWatchdog(); await Promise.all([stopClient(), server.stop()]); } const clientExited = new Promise((resolve) => { const settle = (code: number | null) => { if (!tieLifetimes) { resolve(code); return; } stopping = true; removeSignalHandlers(); cleanupWatchdog(); const finish = () => resolve(code); server.stop().then(finish, finish); }; client.once('exit', (code) => settle(code)); client.once('error', (error) => { console.error('[mock-server] Failed to spawn client command:', error); settle(null); }); }); // Every path this process can still observe tears the client group down. // SIGHUP matters for a closing terminal; 'exit' catches a plain return or an // uncaught throw. (A SIGKILL of this process runs none of them — that is // what the watchdog above is for.) const onSignal = () => { void stop(); }; const onExit = () => { // Synchronous only: no async work runs during 'exit'. cleanupWatchdog(); killClientTree(client, 'SIGKILL'); }; function removeSignalHandlers(): void { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); process.off('SIGHUP', onSignal); process.off('exit', onExit); } if (tieLifetimes) { process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); process.on('SIGHUP', onSignal); process.on('exit', onExit); } return { server, port: boundPort, client, clientExited, stop }; }