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);