From aea128b6061ff05605d63e1d82a4a48bc0a47c22 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 8 Apr 2026 10:58:57 -0700 Subject: [PATCH] feat(cli): smart port selection with instance reuse (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): smart port selection with instance reuse Replace the simple 10-port retry loop with best-in-class port handling: - Multi-host port testing (127.0.0.1, 0.0.0.0, ::1, ::) catches ports occupied by SSH forwarding or other interfaces invisible to localhost - HTTP probe (/__hyperframes_config) detects existing HyperFrames preview servers — reuses same-project instances instead of spawning duplicates, skips different-project instances - PID detection via lsof for actionable "Port N in use by PID X" logs - Expanded scan range from 10 to 100 ports - Added --force-new flag to bypass instance detection - Async PID detection (execFile, no shell) and parallel host testing Fixes the "10 ports are all in use" error that occurs when zombie preview servers accumulate or devbox port forwarding occupies ports. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(cli): add --list and --kill-all flags to preview command - `hyperframes preview --list` scans the port range and displays all active HyperFrames preview servers with their project name, directory, and PID - `hyperframes preview --kill-all` kills all active preview servers - Port scanning uses parallel batched probes (20 at a time) for speed Gives users visibility into zombie preview servers and a one-command way to clean them up. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/preview.ts | 137 +++++---- packages/cli/src/server/portUtils.ts | 356 ++++++++++++++++++++++++ packages/cli/src/server/studioServer.ts | 13 + 3 files changed, 451 insertions(+), 55 deletions(-) create mode 100644 packages/cli/src/server/portUtils.ts diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 330872eaf..b23e60995 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -6,6 +6,9 @@ export const examples: Example[] = [ ["Preview the current project", "hyperframes preview"], ["Preview a specific project directory", "hyperframes preview ./my-video"], ["Use a custom port", "hyperframes preview --port 8080"], + ["Force a new server even if one is already running", "hyperframes preview --force-new"], + ["List all active preview servers", "hyperframes preview --list"], + ["Kill all active preview servers", "hyperframes preview --kill-all"], ]; import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs"; import { resolve, dirname, basename, join } from "node:path"; @@ -16,65 +19,69 @@ import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; - -/** - * Try to start a server on the given port, auto-incrementing up to maxAttempts - * times if the port is already in use. Returns the running server and actual port. - * - * Uses createAdaptorServer (no auto-listen) so we control the bind and can - * retry on EADDRINUSE without TOCTOU races. - */ -async function serveWithPortFallback( - fetch: Parameters[0]["fetch"], - startPort: number, - maxAttempts = 10, -): Promise<{ server: import("@hono/node-server").ServerType; port: number }> { - const { createAdaptorServer } = await import("@hono/node-server"); - - const server = createAdaptorServer({ fetch }); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const port = startPort + attempt; - 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); - }); - return { server, port }; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "EADDRINUSE") { - continue; // try next port - } - throw err; // unexpected error — don't swallow it - } - } - - const lastPort = startPort + maxAttempts - 1; - throw new Error( - `Ports ${startPort}–${lastPort} are all in use. Use --port to specify a different port.`, - ); -} +import { + findPortAndServe, + scanActiveServers, + killActiveServers, + type FindPortResult, +} from "../server/portUtils.js"; export default defineCommand({ meta: { name: "preview", description: "Start the studio for previewing compositions" }, args: { dir: { type: "positional", description: "Project directory", required: false }, port: { type: "string", description: "Port to run the preview server on", default: "3002" }, + "force-new": { + type: "boolean", + description: "Start a new server even if one is already running for this project", + default: false, + }, + list: { + type: "boolean", + description: "List all active preview servers and exit", + default: false, + }, + "kill-all": { + type: "boolean", + description: "Kill all active preview servers and exit", + default: false, + }, }, async run({ args }) { + const startPort = parseInt(args.port ?? "3002", 10); + + // --list: scan and display active servers + if (args.list) { + const servers = await scanActiveServers(startPort); + if (servers.length === 0) { + console.log("\n No active preview servers found.\n"); + return; + } + console.log(`\n ${c.bold("Active preview servers:")}\n`); + for (const s of servers) { + const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : ""; + console.log( + ` ${c.accent(`Port ${s.port}`)} ${s.projectName} ${c.dim(s.projectDir)}${pidStr}`, + ); + } + console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + return; + } + + // --kill-all: kill all active servers + if (args["kill-all"]) { + const servers = await scanActiveServers(startPort); + if (servers.length === 0) { + console.log("\n No active preview servers to kill.\n"); + return; + } + const killed = await killActiveServers(startPort); + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + return; + } + const rawArg = args.dir; const dir = resolve(rawArg ?? "."); - const startPort = parseInt(args.port ?? "3002", 10); // Compute display name: preserve symlink/CWD name when user runs "hyperframes preview ." const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; @@ -102,7 +109,8 @@ export default defineCommand({ return runLocalStudioMode(dir, projectName); } - return runEmbeddedMode(dir, startPort, projectName); + const forceNew = !!args["force-new"]; + return runEmbeddedMode(dir, startPort, projectName, forceNew); }, }); @@ -300,11 +308,15 @@ async function runLocalStudioMode(dir: string, projectName?: string): Promise { const { createStudioServer } = await import("../server/studioServer.js"); @@ -315,9 +327,9 @@ async function runEmbeddedMode( const s = clack.spinner(); s.start("Starting studio..."); - let actualPort: number; + let result: FindPortResult; try { - ({ port: actualPort } = await serveWithPortFallback(app.fetch, startPort)); + result = await findPortAndServe(app.fetch, startPort, dir, forceNew); } catch (err: unknown) { s.stop(c.error("Failed to start studio")); console.error(); @@ -327,11 +339,26 @@ async function runEmbeddedMode( return; } - const url = `http://localhost:${actualPort}`; + if (result.type === "already-running") { + const url = `http://localhost:${result.port}`; + s.stop(c.success("Already running")); + console.log(); + console.log(` ${c.dim("Project")} ${c.accent(pName)}`); + console.log(` ${c.dim("Studio")} ${c.accent(url)}`); + console.log(); + console.log( + ` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`, + ); + console.log(); + import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {}); + return; + } + + const url = `http://localhost:${result.port}`; s.stop(c.success("Studio running")); console.log(); - if (actualPort !== startPort) { - console.log(` ${c.warn(`Port ${startPort} is in use, using ${actualPort} instead`)}`); + if (result.port !== startPort) { + console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); console.log(); } console.log(` ${c.dim("Project")} ${c.accent(pName)}`); diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts new file mode 100644 index 000000000..d66a74e7d --- /dev/null +++ b/packages/cli/src/server/portUtils.ts @@ -0,0 +1,356 @@ +/** + * Port utilities for the HyperFrames preview server. + * + * Implements Remotion-style port handling: + * - 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. + * Returns false (unavailable) only for EADDRINUSE. Other errors (e.g., + * EADDRNOTAVAIL when IPv6 is disabled) are treated as "this host doesn't + * apply" and return true. + */ +function isPortAvailableOnHost(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.on("error", (err: NodeJS.ErrnoException) => { + resolve(err.code !== "EADDRINUSE"); + }); + server.listen({ port, host }, () => { + server.close(() => { + resolve(true); + }); + }); + }); +} + +/** + * Test a port across IPv4 and IPv6 interfaces in parallel. A port is only + * unavailable if ANY host reports EADDRINUSE. This catches the devbox bug + * where a port is free on localhost but occupied on 0.0.0.0 via SSH forwarding. + */ +export async function testPortOnAllHosts(port: number): Promise { + const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"]; + const results = await Promise.all(hosts.map((h) => isPortAvailableOnHost(port, h))); + return results.every(Boolean); +} + +// ── Existing instance detection ──────────────────────────────────────────── + +interface HyperframesConfigResponse { + isHyperframes: boolean; + projectName: string; + projectDir: string; + 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, +): 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) { + 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. + */ +export 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; + 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 = 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 (Remotion-style). + * + * 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, +): Promise { + const { createAdaptorServer } = await import("@hono/node-server"); + 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); + }); + 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); + 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.`, + ); +} diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index e060745bb..a4d48a34e 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -10,6 +10,7 @@ import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; +import { VERSION as version } from "../version.js"; import { createStudioApi, getMimeType, @@ -240,6 +241,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const app = new Hono(); + // Config probe endpoint — used by port detection to identify existing + // HyperFrames instances and reuse them instead of spawning duplicates. + // See portUtils.ts detectHyperframesServer() for the consumer. + app.get("/__hyperframes_config", (c) => { + return c.json({ + isHyperframes: true, + projectName: projectId, + projectDir: projectDir, + version, + }); + }); + // CLI-specific routes (before shared API) app.get("/api/runtime.js", (c) => { if (!existsSync(runtimePath)) return c.text("runtime not built", 404);