#!/usr/bin/env node // Spawns the built API server, waits for /api/healthz, runs a wrapped // command, and tears the server down on exit. If TEST_API_BASE is set in // the environment, the spawn is skipped and the wrapped command runs // against that externally-managed server. If a healthy API server is // already running on the target port (e.g. the dev API Server workflow), // the helper reuses it instead of trying to bind a second process. // // Usage: // node ./scripts/with-server.mjs -- [args...] // // Env knobs: // TEST_API_BASE If set, skip spawning and use this URL. // PORT Port to bind the spawned server to (default 8080). // TEST_API_WAIT_TIMEOUT_MS Health-wait timeout (default 60000 here). // TEST_API_WAIT_INTERVAL_MS Poll interval (default 500). // API_SERVER_LOG_TAIL Number of stderr/stdout lines to dump on // startup failure (default 60). // WITH_SERVER_FORCE_BUILD=1 Force a rebuild even if dist/index.mjs exists. import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const apiServerDir = path.resolve(scriptDir, ".."); function parseArgs(argv) { const sep = argv.indexOf("--"); const rest = sep === -1 ? argv : argv.slice(sep + 1); if (rest.length === 0) { console.error( "with-server.mjs: missing wrapped command. Usage: with-server.mjs -- [args...]", ); process.exit(2); } return { cmd: rest[0], cmdArgs: rest.slice(1) }; } function tail(buffer, n) { const lines = buffer.split(/\r?\n/); return lines.slice(-n).join("\n"); } async function isAlreadyHealthy(url) { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1_500); const res = await fetch(url, { signal: controller.signal }); clearTimeout(timer); if (!res.ok) return false; const body = await res.json().catch(() => ({})); return body && body.status === "ok"; } catch { return false; } } async function waitForHealth(url, timeoutMs, intervalMs, isChildAlive) { const started = Date.now(); let attempt = 0; let lastError = null; while (Date.now() - started < timeoutMs) { if (!isChildAlive()) { return { ok: false, reason: "API server child exited before becoming healthy", lastError, attempts: attempt, }; } attempt += 1; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2_000); const res = await fetch(url, { signal: controller.signal }); clearTimeout(timer); if (res.ok) { const body = await res.json().catch(() => ({})); if (body && body.status === "ok") { return { ok: true, attempts: attempt, elapsedMs: Date.now() - started }; } lastError = new Error(`Unexpected health payload: ${JSON.stringify(body)}`); } else { lastError = new Error(`HTTP ${res.status} from ${url}`); } } catch (err) { lastError = err; } await new Promise((r) => setTimeout(r, intervalMs)); } return { ok: false, reason: `API server did not become healthy within ${timeoutMs}ms`, lastError, attempts: attempt, }; } // Centralized lifecycle so signal handlers, top-level errors, and the // happy-path exit all funnel through the same shutdown sequence. This // guarantees the spawned API server is killed exactly once and that // repeated signals never short-circuit an in-flight teardown. function createLifecycle() { let server = null; let serverExited = false; let serverExitCode = null; let serverExitSignal = null; let shutdownPromise = null; let activeChild = null; function setServer(proc) { server = proc; server.on("exit", (code, signal) => { serverExited = true; serverExitCode = code; serverExitSignal = signal; }); } function setActiveChild(proc) { activeChild = proc; } function clearActiveChild(proc) { if (activeChild === proc) activeChild = null; } function shutdown(signal = "SIGTERM") { if (shutdownPromise) return shutdownPromise; shutdownPromise = (async () => { // Forward signal to wrapped command first so it can flush. if (activeChild && !activeChild.killed) { try { activeChild.kill(signal); } catch { /* noop */ } } if (!server || serverExited) return; await new Promise((resolve) => { const onExit = () => resolve(); server.once("exit", onExit); try { server.kill(signal); } catch { server.removeListener("exit", onExit); resolve(); return; } const force = setTimeout(() => { try { if (!serverExited) server.kill("SIGKILL"); } catch { /* noop */ } }, 5_000); server.once("exit", () => clearTimeout(force)); }); })(); return shutdownPromise; } return { setServer, setActiveChild, clearActiveChild, shutdown, get serverExited() { return serverExited; }, get serverExitCode() { return serverExitCode; }, get serverExitSignal() { return serverExitSignal; }, }; } function runWrappedCommand(cmd, cmdArgs, env, lifecycle) { return new Promise((resolve) => { const child = spawn(cmd, cmdArgs, { stdio: "inherit", env, shell: false, }); if (lifecycle) lifecycle.setActiveChild(child); child.on("exit", (code, signal) => { if (lifecycle) lifecycle.clearActiveChild(child); resolve({ code: code ?? (signal ? 1 : 0), signal }); }); child.on("error", (err) => { if (lifecycle) lifecycle.clearActiveChild(child); console.error(`with-server.mjs: failed to spawn wrapped command: ${err.message}`); resolve({ code: 1, signal: null }); }); }); } function buildIfNeeded() { const distEntry = path.join(apiServerDir, "dist", "index.mjs"); if (existsSync(distEntry) && process.env.WITH_SERVER_FORCE_BUILD !== "1") { return Promise.resolve(); } return new Promise((resolve, reject) => { const child = spawn("node", ["./build.mjs"], { cwd: apiServerDir, stdio: "inherit", env: process.env, }); child.on("exit", (code) => { if (code === 0) resolve(); else reject(new Error(`API server build failed with exit code ${code}`)); }); child.on("error", (err) => reject(err)); }); } async function main() { const { cmd, cmdArgs } = parseArgs(process.argv.slice(2)); // Externally-managed server: skip spawn entirely. if (process.env.TEST_API_BASE) { console.log( `with-server.mjs: TEST_API_BASE=${process.env.TEST_API_BASE} set; using existing server.`, ); const { code } = await runWrappedCommand(cmd, cmdArgs, process.env, null); process.exit(code); } const port = process.env.PORT ?? "8080"; const apiBase = `http://localhost:${port}`; const healthUrl = `${apiBase}/api/healthz`; if (await isAlreadyHealthy(healthUrl)) { console.log( `with-server.mjs: detected healthy API server at ${healthUrl}; reusing it (skipping spawn).`, ); const reuseEnv = { ...process.env, TEST_API_BASE: apiBase }; const { code } = await runWrappedCommand(cmd, cmdArgs, reuseEnv, null); process.exit(code); } await buildIfNeeded(); const tailLines = Number(process.env.API_SERVER_LOG_TAIL ?? 60); const timeoutMs = Number(process.env.TEST_API_WAIT_TIMEOUT_MS ?? 60_000); const intervalMs = Number(process.env.TEST_API_WAIT_INTERVAL_MS ?? 500); const serverEnv = { ...process.env, PORT: port, NODE_ENV: process.env.NODE_ENV ?? "development", }; const lifecycle = createLifecycle(); // Install centralized signal/error handlers ONCE so repeated signals // share the same in-flight shutdown promise instead of racing. const onSignal = (sig) => { lifecycle.shutdown(sig).finally(() => { const exitCode = sig === "SIGINT" ? 130 : 143; process.exit(exitCode); }); }; process.on("SIGINT", () => onSignal("SIGINT")); process.on("SIGTERM", () => onSignal("SIGTERM")); process.on("uncaughtException", async (err) => { console.error("with-server.mjs: uncaught exception", err); try { await lifecycle.shutdown("SIGTERM"); } finally { process.exit(1); } }); process.on("unhandledRejection", async (err) => { console.error("with-server.mjs: unhandled rejection", err); try { await lifecycle.shutdown("SIGTERM"); } finally { process.exit(1); } }); console.log(`with-server.mjs: starting API server on ${apiBase} ...`); const server = spawn("node", ["--enable-source-maps", "./dist/index.mjs"], { cwd: apiServerDir, env: serverEnv, stdio: ["ignore", "pipe", "pipe"], }); lifecycle.setServer(server); let stdoutBuf = ""; let stderrBuf = ""; const MAX_BUF = 256 * 1024; server.stdout.on("data", (chunk) => { stdoutBuf += chunk.toString(); if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF); process.stdout.write(chunk); }); server.stderr.on("data", (chunk) => { stderrBuf += chunk.toString(); if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF); process.stderr.write(chunk); }); const health = await waitForHealth( healthUrl, timeoutMs, intervalMs, () => !lifecycle.serverExited, ); if (!health.ok) { console.error(`\nwith-server.mjs: ${health.reason}`); if (health.lastError) { console.error( `Last health-check error: ${health.lastError.message ?? health.lastError}`, ); } if (lifecycle.serverExited) { console.error( `API server exited prematurely (code=${lifecycle.serverExitCode}, signal=${lifecycle.serverExitSignal}).`, ); } if (stderrBuf.trim()) { console.error(`\n----- API server stderr (last ${tailLines} lines) -----`); console.error(tail(stderrBuf, tailLines)); console.error(`----- end stderr -----`); } if (!stderrBuf.trim() && stdoutBuf.trim()) { console.error(`\n----- API server stdout (last ${tailLines} lines) -----`); console.error(tail(stdoutBuf, tailLines)); console.error(`----- end stdout -----`); } console.error( "\nHint: API server failed to start. Check the logs above, or set TEST_API_BASE to point at a running instance.", ); await lifecycle.shutdown("SIGTERM"); process.exit(1); } console.log( `with-server.mjs: API server ready at ${healthUrl} after ${health.elapsedMs}ms (attempt ${health.attempts}).`, ); const wrappedEnv = { ...process.env, TEST_API_BASE: apiBase }; const { code, signal } = await runWrappedCommand(cmd, cmdArgs, wrappedEnv, lifecycle); await lifecycle.shutdown("SIGTERM"); if (signal) { process.exit(128 + (signal === "SIGINT" ? 2 : 15)); } process.exit(code ?? 0); } main().catch(async (err) => { console.error("with-server.mjs: unexpected error", err); process.exit(1); });