mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* feat(preview): keep review sessions alive * fix(preview): harden background lifecycle state * refactor(preview): satisfy lifecycle quality gate
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
/**
|
||
* 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.`,
|
||
);
|
||
}
|