/** * HTTP server — serves the HTML overview for remote browsers. * * Uses Node's built-in http module (zero deps). The server is a dumb * byte-pipe: it reads the HTML file on each request. Lifecycle is governed * by an idle timeout, with session_shutdown and process exit as backstops. */ import { createServer, type Server } from "node:http"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { networkInterfaces } from "node:os"; import { randomUUID } from "node:crypto"; import type { Options } from "./types.ts"; const HTML_FILE = "pi-onboard-overview.html"; export interface ServerInfo { urls: string[]; reused: boolean; port: number; } /** Module-level singleton server state. */ let activeServer: Server | null = null; let activePort = 0; let activeToken = ""; let activeHost = "0.0.0.0"; let activeCwd = ""; let idleTimer: ReturnType | null = null; /** * Ensure a server is running and serving the HTML overview. * Reuses an existing server if one is live; otherwise starts a new one. */ export async function ensureServer(cwd: string, opts: Options): Promise { if (opts.noServe || opts.textOnly) return null; // Reuse existing server — update cwd so it serves the latest HTML if (activeServer && activeServer.listening) { activeCwd = cwd; resetIdleTimer(opts.idleTimeout); return { urls: buildUrls(activeHost, activePort, activeToken), reused: true, port: activePort }; } // Start new server const token = randomUUID(); const host = opts.host; const port = opts.port ?? 0; const server = createServer((req, res) => { const url = req.url ?? "/"; // Only serve under the token path if (!url.startsWith(`/${token}/`) && url !== `/${token}`) { res.writeHead(404); res.end("Not found"); return; } // Read the HTML file fresh each request (dumb byte-pipe) // Uses activeCwd so reused servers serve the latest repo's HTML const htmlPath = join(activeCwd || cwd, HTML_FILE); try { const content = readFileSync(htmlPath); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(content); } catch { res.writeHead(404); res.end("Overview not generated yet."); } // Reset idle timer on each request resetIdleTimer(opts.idleTimeout); }); // Wait for the server to actually be listening so we know the port await new Promise((resolve, reject) => { server.on("error", reject); server.listen(port, host, () => { const addr = server.address(); activePort = typeof addr === "object" && addr ? addr.port : port; resolve(); }); }); activeServer = server; activeToken = token; activeHost = host; activeCwd = cwd; // Start idle timer resetIdleTimer(opts.idleTimeout); return { urls: buildUrls(host, activePort, token), reused: false, port: activePort }; } /** Close any active server (called on session_shutdown). */ export function closeServer(): void { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (activeServer) { activeServer.close(); activeServer = null; } activeCwd = ""; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resetIdleTimer(timeoutMin: number): void { if (idleTimer) clearTimeout(idleTimer); if (timeoutMin > 0) { idleTimer = setTimeout(() => { closeServer(); }, timeoutMin * 60 * 1000); // Don't keep the process alive just for the timer idleTimer?.unref?.(); } } /** Build candidate URLs from network interfaces. */ function buildUrls(host: string, port: number, token: string): string[] { const urls: string[] = []; if (host === "0.0.0.0" || host === "::") { // Enumerate all non-internal IPv4 addresses try { const interfaces = networkInterfaces(); for (const [name, addrs] of Object.entries(interfaces)) { if (!addrs) continue; for (const addr of addrs) { if (addr.family === "IPv4" && !addr.internal) { urls.push(`http://${addr.address}:${port}/${token}/`); } } } } catch { // fall through to localhost } if (urls.length === 0) { urls.push(`http://localhost:${port}/${token}/`); } } else { urls.push(`http://${host}:${port}/${token}/`); } return urls; }