mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
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:
co-authored by
Claude Opus 4.6
parent
fe9cd301ec
commit
aea128b606
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user