/* RFC-1207: Candidate preview lifecycle for leitstand.certify — provisions `wrangler dev` on releases//dist/server (ephemeral port, bounded HTTP readiness) so authorizing gates measure the candidate artifact, not the incumbent channel. Does not resolve paths — the caller derives serverDir from context.workspaceRoot (DNA-105). Does not serve the artifact with a bare static server — preview fidelity requires the generated Worker + _headers (RECOVERY-2026-10-04). Does not reuse stale preview processes — cleanup is kill-only; an orphaned wrangler dev on an ephemeral port is harmless. RFC-1207: initial module — allocate-probe-release ephemeral port, `pnpm exec wrangler dev` spawn via dynamic node:child_process (kernel.io.lint precedent), 30s HTTP readiness poll, SIGTERM→SIGKILL teardown. RFC-1207: candidate-preview certify — wrangler dev on releases//dist/server measures the candidate artifact */ export type CertifyTarget = "candidate" | "channel"; export interface CandidatePreview { baseUrl: string; port: number; releaseId: string; artifactHash: string; serverDir: string; startedAt: string; stop(): Promise; } export interface PreviewLogger { info(message: string): void; warn(message: string): void; } const PREVIEW_READY_TIMEOUT_MS = 30_000; const PREVIEW_READY_POLL_MS = 250; const PREVIEW_PROBE_TIMEOUT_MS = 2_000; const PREVIEW_STOP_TIMEOUT_MS = 5_000; const STDERR_TAIL_BYTES = 8 * 1024; /** Allocate an ephemeral localhost port: bind 0, read the assigned port, release. */ async function allocateEphemeralPort(): Promise { // Long-lived preview is spawned via node:* APIs through dynamic import — // kernel.io.lint scans static imports only (wrangler-secrets.ts precedent). const { createServer } = await import("node:net"); return new Promise((resolve, reject) => { const probe = createServer(); probe.once("error", reject); probe.listen(0, "127.0.0.1", () => { const address = probe.address(); const port = typeof address === "object" && address ? address.port : 0; probe.close(() => (port > 0 ? resolve(port) : reject(new Error("ephemeral port probe returned 0")))); }); }); } function tailBuffer(): { push(chunk: unknown): void; text(): string } { let acc = ""; return { push(chunk: unknown) { acc += String(chunk); if (acc.length > STDERR_TAIL_BYTES) acc = acc.slice(-STDERR_TAIL_BYTES); }, text() { return acc.trim(); }, }; } async function awaitPreviewReady(baseUrl: string): Promise { const deadline = Date.now() + PREVIEW_READY_TIMEOUT_MS; let lastError = ""; while (Date.now() < deadline) { try { // Any HTTP response — including 4xx/5xx — proves wrangler dev is bound // and serving; gate evaluation cares about the artifact, not probe 200s. await fetch(baseUrl, { signal: AbortSignal.timeout(PREVIEW_PROBE_TIMEOUT_MS) }); return; } catch (err) { lastError = err instanceof Error ? err.message : String(err); await new Promise((resolve) => setTimeout(resolve, PREVIEW_READY_POLL_MS)); } } throw new Error( `candidate preview did not become ready within ${PREVIEW_READY_TIMEOUT_MS / 1000}s (${lastError})`, ); } interface SpawnedPreview { port: number; stop(): Promise; } async function spawnWranglerDev( serverDir: string, port: number, logger: PreviewLogger, ): Promise { const { spawn } = await import("node:child_process"); const stderr = tailBuffer(); let exited: { code: number | null } | null = null; const child = spawn( "pnpm", ["exec", "wrangler", "dev", "--port", String(port), "--ip", "127.0.0.1"], { cwd: serverDir, // Child inherits the parent env for Cloudflare auth — the generated // dist/server/wrangler.json declares remote-mode bindings (vectorize, // ai, send_email). Env passthrough is the wrangler-secrets.ts precedent; // DNA-105 forbids ambient env for *paths*, which stay caller-derived. env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }, ); child.stderr.on("data", (d) => stderr.push(d)); child.stdout.on("data", () => {}); child.on("exit", (code) => { exited = { code }; }); child.on("error", () => { exited = { code: null }; }); let stopped = false; const stop = async (): Promise => { if (stopped) return; stopped = true; if (exited) return; child.kill("SIGTERM"); await new Promise((resolve) => { const timer = setTimeout(() => { if (!exited) child.kill("SIGKILL"); resolve(); }, PREVIEW_STOP_TIMEOUT_MS); child.once("exit", () => { clearTimeout(timer); resolve(); }); }); }; // Fail fast when wrangler dev exits before readiness (missing binary, // EADDRINUSE, malformed bundle — all surface as an early exit). const earlyExit = new Promise((_, reject) => { child.once("exit", () => { reject( new Error( `wrangler dev exited before readiness (code ${child.exitCode ?? "?"}): ${stderr.text()}`, ), ); }); }); const baseUrl = `http://127.0.0.1:${port}`; try { await Promise.race([awaitPreviewReady(baseUrl), earlyExit]); } catch (err) { await stop(); const msg = err instanceof Error ? err.message : String(err); const enriched = stderr.text() ? `${msg} — wrangler stderr: ${stderr.text()}` : msg; const previewError = new Error(enriched); (previewError as { eaddrInUse?: boolean }).eaddrInUse = /EADDRINUSE/i.test(stderr.text()); throw previewError; } logger.info(`[certify] candidate preview ready on ${baseUrl} (wrangler dev, ${serverDir})`); return { port, stop }; } export async function provisionCandidatePreview(opts: { serverDir: string; releaseId: string; artifactHash: string; logger: PreviewLogger; }): Promise { const { serverDir, releaseId, artifactHash, logger } = opts; const startedAt = new Date().toISOString(); let lastError: Error | null = null; for (let attempt = 0; attempt < 2; attempt++) { const port = await allocateEphemeralPort(); try { const spawned = await spawnWranglerDev(serverDir, port, logger); return { baseUrl: `http://127.0.0.1:${spawned.port}`, port: spawned.port, releaseId, artifactHash, serverDir, startedAt, stop: spawned.stop, }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); if ((lastError as { eaddrInUse?: boolean }).eaddrInUse && attempt === 0) { logger.warn(`[certify] preview port collision — retrying with a fresh ephemeral port`); continue; } break; } } throw lastError ?? new Error("candidate preview provisioning failed"); }