/** * Port utilities for the HyperFrames preview server. * * The multi-host availability probe and instance-reuse port selection are * inspired by Remotion's approach to dev-server port management. * * - Multi-host availability testing (catches port-forwarding ghosts) * - HTTP probe for detecting existing HyperFrames instances * - PID detection for actionable conflict logging * - Smart port selection with instance reuse */ import net from "node:net"; import http from "node:http"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve } from "node:path"; import { c } from "../ui/colors.js"; const execFileAsync = promisify(execFile); /** Max ports to scan before giving up. */ const MAX_PORT_SCAN = 100; /** Localhost HTTP probe timeout — HyperFrames responds in <1ms, so 300ms is generous. */ const PROBE_TIMEOUT_MS = 300; /** Max bytes to read from HTTP probe response (guards against malicious servers). */ const PROBE_MAX_BYTES = 4096; // ── Port availability ────────────────────────────────────────────────────── /** * Test whether a port is free on a specific host. * * Attempts an ephemeral bind-and-release with `net.createServer()`. Only * `EADDRINUSE` means "genuinely occupied" — other errnos (EADDRNOTAVAIL when * IPv6 is disabled, EACCES for privileged ports, EAFNOSUPPORT for missing * address families) mean "this host doesn't apply to our probe", and we treat * the port as free for this host rather than poisoning the whole scan. */ async function isPortAvailableOnHost(port: number, host: string): Promise { const probe = net.createServer(); probe.unref(); const bindError = await new Promise((settle) => { const handleError = (err: NodeJS.ErrnoException): void => settle(err); probe.once("error", handleError); probe.listen({ port, host }, () => { probe.removeListener("error", handleError); settle(null); }); }); if (bindError !== null) { return bindError.code !== "EADDRINUSE"; } await new Promise((done) => probe.close(() => done())); return true; } export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; /** * Test a port across IPv4 and IPv6 interfaces. A port is only available if * EVERY host binds and releases cleanly — that catches the devbox class of * bug where a port is free on `127.0.0.1` but held on `0.0.0.0` via SSH * forwarding. * * **Must be sequential, not Promise.all.** Binding `127.0.0.1` holds the * socket open until `server.close()` resolves on the next event-loop tick. * In parallel, the wildcard `0.0.0.0` / `::` tests race that still-open * socket and return spurious `EADDRINUSE` — which makes every port in the * scan range look occupied and the preview server refuse to start. Repro * on Linux (Crostini on ChromeOS in the reporting environment, issue #309) * is deterministic; on macOS/Windows the behaviour is less consistent but * the race is there all the same. Serializing each bind past its close * callback eliminates the window entirely. * * `probe` is injectable for deterministic testing of the sequential * contract — callers in production pass nothing and get the real socket * probe. Tests can pass a recording fake that tracks in-flight probes. */ export async function testPortOnAllHosts( port: number, probe: (port: number, host: string) => Promise = isPortAvailableOnHost, ): Promise { for (const host of PORT_PROBE_HOSTS) { const available = await probe(port, host); if (!available) return false; } return true; } // ── Existing instance detection ──────────────────────────────────────────── interface HyperframesConfigResponse { isHyperframes: boolean; pid?: number; projectName: string; projectDir: string; serverBuildSignature?: string | null; version: string; } export type DetectionResult = | { type: "match" } | { type: "mismatch"; projectName: string } | { type: "not-hyperframes" }; /** * Probe an occupied port to check if it's running a HyperFrames preview server. * HTTP GET to /__hyperframes_config with a short timeout. */ export function detectHyperframesServer( port: number, normalizedProjectDir: string, expectedServerBuildSignature: string | null = null, ): Promise { return new Promise((resolveResult) => { const req = http.get( { hostname: "127.0.0.1", port, path: "/__hyperframes_config", timeout: PROBE_TIMEOUT_MS, }, (res) => { if (res.statusCode !== 200) { res.resume(); return resolveResult({ type: "not-hyperframes" }); } let data = ""; let bytes = 0; res.on("data", (chunk: Buffer | string) => { bytes += typeof chunk === "string" ? chunk.length : chunk.byteLength; if (bytes > PROBE_MAX_BYTES) { req.destroy(); return resolveResult({ type: "not-hyperframes" }); } data += chunk; }); res.on("error", () => { resolveResult({ type: "not-hyperframes" }); }); res.on("end", () => { try { const json = JSON.parse(data) as HyperframesConfigResponse; if (json.isHyperframes !== true) { return resolveResult({ type: "not-hyperframes" }); } const normalize = (p: string) => resolve(p).replace(/\\/g, "/").toLowerCase(); if (normalize(json.projectDir) === normalizedProjectDir) { if ( expectedServerBuildSignature !== null && json.serverBuildSignature !== expectedServerBuildSignature ) { return resolveResult({ type: "mismatch", projectName: json.projectName }); } return resolveResult({ type: "match" }); } return resolveResult({ type: "mismatch", projectName: json.projectName }); } catch { resolveResult({ type: "not-hyperframes" }); } }); }, ); req.on("error", () => { resolveResult({ type: "not-hyperframes" }); }); req.on("timeout", () => { req.destroy(); resolveResult({ type: "not-hyperframes" }); }); }); } // ── PID detection ────────────────────────────────────────────────────────── /** * Get the PID of the process listening on a port (macOS/Linux only). * Returns null on Windows or if detection fails. */ async function getProcessOnPort(port: number): Promise { if (process.platform === "win32") return null; try { const { stdout } = await execFileAsync("lsof", [`-ti:${port}`, "-sTCP:LISTEN"], { timeout: 2000, }); const pid = stdout.trim().split("\n")[0]?.trim(); return pid || null; } catch { return null; } } // ── Server discovery ─────────────────────────────────────────────────────── export interface ActiveServer { port: number; /** * Loopback host the server is reachable on, URL-ready (`127.0.0.1` or * `[::1]`). Vite dev servers bind IPv6 (`::1`) while embedded servers bind * IPv4; consumers must use this rather than assuming `127.0.0.1`. Defaults to * `127.0.0.1` when unset (embedded scan path). */ host?: string; projectName: string; projectDir: string; version: string; pid: string | null; } /** * Probe a single port for a HyperFrames config response. * Returns the full config or null if not a HyperFrames server. */ function probePort(port: number): Promise { return new Promise((resolveResult) => { const req = http.get( { hostname: "127.0.0.1", port, path: "/__hyperframes_config", timeout: PROBE_TIMEOUT_MS }, (res) => { if (res.statusCode !== 200) { res.resume(); return resolveResult(null); } let data = ""; let bytes = 0; res.on("data", (chunk: Buffer | string) => { bytes += typeof chunk === "string" ? chunk.length : chunk.byteLength; if (bytes > PROBE_MAX_BYTES) { req.destroy(); return resolveResult(null); } data += chunk; }); res.on("error", () => resolveResult(null)); res.on("end", () => { try { const json = JSON.parse(data) as HyperframesConfigResponse; resolveResult(json.isHyperframes === true ? json : null); } catch { resolveResult(null); } }); }, ); req.on("error", () => resolveResult(null)); req.on("timeout", () => { req.destroy(); resolveResult(null); }); }); } /** * Scan the default port range for active HyperFrames preview servers. * Probes ports in parallel batches for speed. */ export async function scanActiveServers(startPort = 3002): Promise { const endPort = startPort + MAX_PORT_SCAN - 1; const servers: ActiveServer[] = []; // Probe in batches of 20 to avoid too many concurrent connections const batchSize = 20; for (let batchStart = startPort; batchStart <= endPort; batchStart += batchSize) { const batchEnd = Math.min(batchStart + batchSize - 1, endPort); const ports = Array.from({ length: batchEnd - batchStart + 1 }, (_, i) => batchStart + i); const results = await Promise.all( ports.map(async (port) => { const config = await probePort(port); if (!config) return null; const pid = Number.isInteger(config.pid) && Number(config.pid) > 0 ? String(config.pid) : await getProcessOnPort(port); return { port, projectName: config.projectName, projectDir: config.projectDir, version: config.version, pid, }; }), ); for (const r of results) { if (r) servers.push(r); } } return servers; } /** * Kill all active HyperFrames preview servers by sending SIGTERM to their PIDs. * Returns the number of servers killed. */ export async function killActiveServers(startPort = 3002): Promise { const servers = await scanActiveServers(startPort); let killed = 0; for (const server of servers) { if (server.pid) { try { process.kill(parseInt(server.pid, 10), "SIGTERM"); killed++; } catch { // Process may have already exited } } } return killed; } // ── Smart port selection ─────────────────────────────────────────────────── export type FindPortResult = | { type: "started"; server: import("@hono/node-server").ServerType; port: number } | { type: "already-running"; port: number }; /** * Smart port selection with instance reuse (inspired by Remotion's dev-server * port handling). * * For each port in the scan range: * 1. Test availability on multiple hosts (catches port-forwarding ghosts) * 2. If available → bind the server and return * 3. If occupied and !forceNew → HTTP-probe for an existing HyperFrames server * - Same project → return "already-running" (caller reopens browser) * - Different project or non-HyperFrames → log and skip to next port * 4. If bind still fails with EADDRINUSE (race) → retry next port */ export async function findPortAndServe( fetch: Parameters[0]["fetch"], startPort: number, projectDir: string, forceNew: boolean, expectedServerBuildSignature: string | null = null, bindHost?: string, ): Promise { const { createAdaptorServer } = await import("@hono/node-server"); // SECURITY (F-001): bind to loopback by default. The studio API exposes // unauthenticated project file read/write/delete + render-spawn endpoints; // a bare `listen(port)` binds the unspecified address (`::`/`0.0.0.0`), // handing those endpoints to anyone on the LAN. Operators who genuinely // need LAN exposure opt in explicitly via the HYPERFRAMES_PREVIEW_HOST // env var (e.g. HYPERFRAMES_PREVIEW_HOST=0.0.0.0). const host = bindHost ?? (process.env.HYPERFRAMES_PREVIEW_HOST?.trim() || "127.0.0.1"); const normalizedDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase(); const endPort = startPort + MAX_PORT_SCAN - 1; let server: import("@hono/node-server").ServerType | null = null; for (let port = startPort; port <= endPort; port++) { const available = await testPortOnAllHosts(port); if (available) { // Lazily create server on first available port if (!server) server = createAdaptorServer({ fetch }); try { await new Promise((resolveListener, rejectListener) => { const onError = (err: NodeJS.ErrnoException): void => { server!.removeListener("listening", onListening); rejectListener(err); }; const onListening = (): void => { server!.removeListener("error", onError); resolveListener(); }; server!.once("error", onError); server!.once("listening", onListening); server!.listen(port, host); }); return { type: "started", server, port }; } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") { continue; } throw err; } } // Port is occupied — probe for existing HyperFrames instance if (!forceNew) { const detection = await detectHyperframesServer( port, normalizedDir, expectedServerBuildSignature, ); if (detection.type === "match") { return { type: "already-running", port }; } if (detection.type === "mismatch") { console.log( ` ${c.dim(`Port ${port} in use by HyperFrames project "${detection.projectName}" — skipping`)}`, ); continue; } } const pid = await getProcessOnPort(port); if (pid) { console.log(` ${c.dim(`Port ${port} in use by PID ${pid} — skipping`)}`); } } throw new Error( `Ports ${startPort}–${endPort} are all in use. Use --port to specify a different starting port.`, ); }