Files
hyperframes/packages/cli/src/server/portUtils.ts
T
Miguel Ángel 2a7a5e6236 feat(preview): keep review sessions alive (#2384)
* feat(preview): keep review sessions alive

* fix(preview): harden background lifecycle state

* refactor(preview): satisfy lifecycle quality gate
2026-07-13 22:55:19 -04:00

423 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<boolean> {
const probe = net.createServer();
probe.unref();
const bindError = await new Promise<NodeJS.ErrnoException | null>((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<void>((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<boolean> = isPortAvailableOnHost,
): Promise<boolean> {
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<DetectionResult> {
return new Promise<DetectionResult>((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<string | null> {
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<HyperframesConfigResponse | null> {
return new Promise<HyperframesConfigResponse | null>((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<ActiveServer[]> {
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<number> {
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<typeof import("@hono/node-server").serve>[0]["fetch"],
startPort: number,
projectDir: string,
forceNew: boolean,
expectedServerBuildSignature: string | null = null,
bindHost?: string,
): Promise<FindPortResult> {
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<void>((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.`,
);
}