feat(cli): smart port selection with instance reuse (#226)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-08 10:58:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fe9cd301ec
commit aea128b606
3 changed files with 451 additions and 55 deletions
+82 -55
View File
@@ -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<typeof import("@hono/node-server").serve>[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<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);
});
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<vo
/**
* Embedded mode: serve the pre-built studio SPA with a standalone Hono server.
* Works without any additional dependencies — the studio is bundled in dist/.
*
* If an existing HyperFrames server for the same project is detected,
* reuses it instead of starting a new one (unless --force-new is set).
*/
async function runEmbeddedMode(
dir: string,
startPort: number,
projectName?: string,
forceNew = false,
): Promise<void> {
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)}`);
+356
View File
@@ -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<boolean> {
return new Promise<boolean>((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<boolean> {
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<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) {
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<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;
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 = 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 (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<typeof import("@hono/node-server").serve>[0]["fetch"],
startPort: number,
projectDir: string,
forceNew: boolean,
): Promise<FindPortResult> {
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<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);
});
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.`,
);
}
+13
View File
@@ -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);