Files
OpenHands/scripts/dev-static.mjs
T
Rohit Malhotraandopenhands 3207d90e72 feat: add dynamic port allocation with preferred port fallback (#223)
* feat: add dynamic port allocation with preferred port fallback

Implement dynamic port allocation for dev entrypoint scripts to gracefully
handle port conflicts. When a preferred port is busy, the system automatically
finds an alternative available port.

Changes:
- Add findFreePort() and findFreePorts() utilities to dev-safe.mjs
- Add buildSafeDevConfigAsync() for async config with dynamic allocation
- Update dev-with-automation.mjs to use async buildConfig with dynamic ports
- Update dev-static.mjs to use async buildConfig
- Add strictPort: true to vite.config.ts to fail-fast on conflicts
- Update tests for async buildConfig

The utilities try the preferred/default ports first, falling back to
OS-assigned ports only when needed. This preserves predictable defaults
while gracefully handling port conflicts.

Closes #222

* fix: address review feedback - add max retry, document race condition, improve tests

- Add max retry count (100 attempts) to port allocation loop to prevent
  infinite loops
- Fix findFreePort to handle preferredPort=0 correctly by skipping the
  port check and going straight to OS assignment
- Document race condition limitation in findFreePort JSDoc (accepted
  limitation with guidance on handling EADDRINUSE)
- Clarify JSDoc for buildSafeDevConfig vs buildSafeDevConfigAsync with
  clear guidance on when to use each
- Remove misleading 'must be after prereq check' comment
- Add comprehensive tests for findFreePort, findFreePorts, and
  buildSafeDevConfigAsync using actual port blocking
- Improve buildConfig tests with port uniqueness verification and
  fallback tests using high ports
- Use high ports (19xxx range) in tests to avoid conflicts with
  system services

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-09 14:46:10 -04:00

667 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Static-frontend Development Stack
*
* Mirrors `npm run dev` (scripts/dev-with-automation.mjs) but serves a
* production build of the frontend via `sirv-cli` instead of the Vite dev
* server. Designed for slow / flaky network situations (e.g. plane wifi)
* where Vite's ~1000 individual module requests per page load are the
* bottleneck. The static build collapses the frontend into ~50 hashed
* chunks that all 304 cleanly on reload.
*
* Architecture (identical to dev-with-automation, only the frontend differs):
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ http://localhost:8000 (Ingress Proxy) │
* │ /api/automation/* → Automation Backend │
* │ /api/*, /sockets → Agent Server │
* │ /* → Static Frontend │
* └──────────────────────────────────────────────────────────────────────────┘
* │ │ │
* ▼ ▼ ▼
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
* │ sirv-cli │ │ Agent Server │ │ Automation │
* │ build/ │ │ (uvx) :18000 │ │ Backend (uvx) │
* │ :3001 │ │ │ │ :18001 │
* └─────────────┘ └───────────────┘ └──────────────────┘
*
* Usage:
* npm run dev:static
* npm run dev:static -- --port 12000
* npm run dev:static -- --skip-build # reuse an existing build/
* npm run dev:static -- --automation-ref feat/my-branch
*
* Environment variables (all optional, same as dev:automation):
* - PORT: Ingress port (default: 8000)
* - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
* - OH_SECRET_KEY: Session secret key
*/
import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import process from "node:process";
import {
buildAgentServerCommand,
buildSafeDevConfig,
buildAgentServerEnv,
buildNpmScriptCommand,
formatMissingUvxGuidance,
isPortBusy,
releaseStaleConversationLeases,
} from "./dev-safe.mjs";
import {
buildAutomationCommand,
buildConfig,
} from "./dev-with-automation.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
// ═══════════════════════════════════════════════════════════════════════════
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
};
function logService(name, message, color = c.reset) {
const ts = new Date().toISOString().split("T")[1].split(".")[0];
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
}
function logStep(step, message) {
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
}
function logSuccess(message) {
console.log(`${c.green}✓${c.reset} ${message}`);
}
function logError(message) {
console.error(`${c.red}✗${c.reset} ${message}`);
}
// ═══════════════════════════════════════════════════════════════════════════
// CLI parsing
// ═══════════════════════════════════════════════════════════════════════════
export function parseArgs(argv = process.argv.slice(2)) {
const config = {
port: null,
automationGitRef: null,
automationRepo: null,
skipBuild: false,
verbose: false,
};
for (let i = 0; i < argv.length; i++) {
switch (argv[i]) {
case "-p":
case "--port":
config.port = parseInt(argv[++i], 10);
break;
case "--automation-ref":
config.automationGitRef = argv[++i];
break;
case "--automation-repo":
config.automationRepo = argv[++i];
break;
case "--skip-build":
config.skipBuild = true;
break;
case "-v":
case "--verbose":
config.verbose = true;
break;
case "-h":
case "--help":
showHelp();
process.exit(0);
}
}
return config;
}
function showHelp() {
console.log(`
Agent Canvas Static-frontend Development Stack
Same backend stack as \`npm run dev\`, but serves a production build of the
frontend via sirv-cli. Use this when a flaky network makes Vite's per-module
requests painful (e.g. on a plane).
USAGE:
npm run dev:static [-- options]
OPTIONS:
-p, --port <port> Ingress port (default: 8000)
--automation-ref <ref> Git ref for automation backend (default: main)
--automation-repo <url> Git repo URL for automation
--skip-build Reuse existing build/ directory (faster restart)
-v, --verbose Show detailed output
-h, --help Show this help
ENVIRONMENT VARIABLES:
PORT Alternative to --port
OH_AUTOMATION_GIT_REF Alternative to --automation-ref
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK
OH_SECRET_KEY Secret key for sessions
ACCESS POINTS:
Main UI: http://localhost:PORT/
API Docs: http://localhost:PORT/api/automation/docs
NOTES:
• The build is produced once at startup. Edit the source and rerun this
command (or rebuild with \`npm run build:app\`) to pick up changes.
• The static server sends ETag headers, so reloads return 304s instead of
refetching content — much friendlier on slow links.
`);
}
// ═══════════════════════════════════════════════════════════════════════════
// Prerequisites & Setup
// ═══════════════════════════════════════════════════════════════════════════
function commandExists(cmd) {
const result =
process.platform === "win32"
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
return result.status === 0;
}
function checkPrerequisites() {
logStep("1/3", "Checking prerequisites...");
if (!commandExists("uvx")) {
console.error(formatMissingUvxGuidance(projectRoot));
process.exit(1);
}
logSuccess("uvx found");
if (!commandExists("npm")) {
logError("npm is required but not found");
process.exit(1);
}
logSuccess("npm found");
}
// ═══════════════════════════════════════════════════════════════════════════
// Build
// ═══════════════════════════════════════════════════════════════════════════
function buildFrontend(config, args) {
const buildDir = join(config.canvasPath, "build");
if (args.skipBuild) {
if (!existsSync(buildDir)) {
logError(
"--skip-build was passed but build/ does not exist. Run without --skip-build first.",
);
process.exit(1);
}
logStep("2/3", "Skipping build (--skip-build)");
logService("build", `Reusing existing build/ at ${buildDir}`, c.dim);
return;
}
logStep("2/3", "Building frontend (npm run build:app)...");
logService(
"build",
"This typically takes 30–60s; cached as build/ for --skip-build reuse",
c.dim,
);
const cmd = buildNpmScriptCommand("build:app");
const result = spawnSync(cmd.command, cmd.args, {
cwd: config.canvasPath,
stdio: "inherit",
env: {
...process.env,
// Bake the agent-server's workspace path into the build so conversations
// start with the same default working directory as `npm run dev`.
VITE_WORKING_DIR: join(config.stateDir, "workspaces"),
// Bake the automation backend API key so the static frontend can talk
// to /api/automation through the ingress.
VITE_AUTOMATION_API_KEY: config.localApiKey,
// Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes
// the runtime fall back to window.location.origin (i.e. the ingress
// port the user is actually browsing), which keeps the build portable.
},
});
if (result.status !== 0) {
logError(`Build failed with exit code ${result.status ?? "null"}`);
process.exit(result.status ?? 1);
}
if (!existsSync(join(buildDir, "index.html"))) {
logError(
`Build completed but ${join(buildDir, "index.html")} is missing. ` +
`Did react-router build write somewhere unexpected?`,
);
process.exit(1);
}
logSuccess("Build complete");
}
// ═══════════════════════════════════════════════════════════════════════════
// Process Management
// ═══════════════════════════════════════════════════════════════════════════
const processes = new Map();
let shuttingDown = false;
function spawnService(name, command, args, options = {}) {
const proc = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
shell: process.platform === "win32",
});
const color = options.color || c.reset;
proc.stdout.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => logService(name, line.trim(), color));
});
proc.stderr.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => logService(name, line.trim(), c.yellow));
});
proc.on("error", (error) => {
logError(`${name} failed to start: ${error.message}`);
});
proc.on("exit", (code) => {
if (code !== 0 && code !== null && !shuttingDown) {
logService(name, `Exited with code ${code}`, c.red);
}
processes.delete(name);
});
processes.set(name, proc);
return proc;
}
async function waitForService(name, url, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok) {
logService(name, `Ready at ${url}`, c.green);
return true;
}
} catch {
// Keep trying
}
await delay(500);
}
logService(name, `Timeout waiting for ${url}`, c.red);
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Service Starters (agent-server + automation are byte-for-byte the same as
// dev-with-automation; the only difference is the frontend service.)
// ═══════════════════════════════════════════════════════════════════════════
function startAgentServer(config) {
logService(
"agent-server",
`Starting on port ${config.agentServerPort}...`,
c.blue,
);
const agentServerCmd = buildAgentServerCommand(process.env);
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
const safeConfig = buildSafeDevConfig(config.canvasPath, {
...process.env,
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
});
const agentServerEnv = buildAgentServerEnv(safeConfig);
spawnService(
"agent-server",
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"0.0.0.0",
"--port",
String(config.agentServerPort),
],
{
cwd: safeConfig.workspacesPath,
env: agentServerEnv,
color: c.blue,
},
);
}
function startAutomationBackend(config) {
logService(
"automation",
`Starting on port ${config.autoBackendPort}...`,
c.green,
);
const automationCmd = buildAutomationCommand(process.env);
logService("automation", `Using ${automationCmd.source}`, c.dim);
spawnService(
"automation",
automationCmd.command,
[
...automationCmd.args,
"--host",
"0.0.0.0",
"--port",
config.autoBackendPort.toString(),
],
{
cwd: config.stateDir,
env: {
AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`,
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`,
AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
AUTOMATION_LOCAL_API_KEY: config.localApiKey,
AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
FILE_STORE: "local",
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
OPENHANDS_SUPPRESS_BANNER: "1",
},
color: c.green,
},
);
}
function startStaticServer(config) {
// Reuse `vitePort` as the upstream port name so the ingress route table
// below stays identical to dev-with-automation.mjs.
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
// Mirror the proxy targets that vite.config.ts exposes in dev mode so that
// hitting :3001 directly behaves like Vite's dev server (e.g. /server_info
// is forwarded to the agent-server instead of falling back to the SPA
// shell). Without this, /server_info on :3001 returns index.html.
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
spawnService(
"static",
"node",
[
staticServerScript,
"--dir",
join(config.canvasPath, "build"),
"--host",
"0.0.0.0",
"--port",
String(config.vitePort),
"--route",
`/api/automation=http://localhost:${config.autoBackendPort}`,
"--route",
`/api=http://localhost:${config.agentServerPort}`,
"--route",
`/sockets=http://localhost:${config.agentServerPort}`,
"--route",
`/server_info=http://localhost:${config.agentServerPort}`,
"--route",
`/health=http://localhost:${config.agentServerPort}`,
"--route",
`/ready=http://localhost:${config.agentServerPort}`,
"--route",
`/alive=http://localhost:${config.agentServerPort}`,
],
{
cwd: config.canvasPath,
color: c.magenta,
},
);
}
function startIngress(config) {
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
spawnService(
"ingress",
"node",
[
ingressScript,
"--port",
config.ingressPort.toString(),
"--route",
`/api/automation=http://localhost:${config.autoBackendPort}`,
"--route",
`/api=http://localhost:${config.agentServerPort}`,
"--route",
`/sockets=http://localhost:${config.agentServerPort}`,
"--route",
`/server_info=http://localhost:${config.agentServerPort}`,
"--route",
`/health=http://localhost:${config.agentServerPort}`,
"--route",
`/ready=http://localhost:${config.agentServerPort}`,
"--route",
`/alive=http://localhost:${config.agentServerPort}`,
"--default",
`http://localhost:${config.vitePort}`,
],
{
cwd: projectRoot,
color: c.yellow,
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Shutdown / Banner
// ═══════════════════════════════════════════════════════════════════════════
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("");
console.log(`${c.yellow}Shutting down...${c.reset}`);
for (const [name, proc] of processes) {
logService(name, "Stopping...", c.dim);
proc.kill("SIGTERM");
}
setTimeout(() => {
for (const [, proc] of processes) {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}
process.exit(0);
}, 3000);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
function printBanner(config) {
console.log("");
console.log(
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas Static-frontend Stack${c.reset} ${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd(
75,
) + `${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd(
75,
) + `${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`,
);
console.log("");
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
console.log(
`${c.dim}Frontend served from: ${join(config.canvasPath, "build")}${c.reset}`,
);
console.log(
`${c.dim}Edit sources, then re-run \`npm run dev:static\` to rebuild.${c.reset}`,
);
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
console.log("");
}
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
async function main() {
const args = parseArgs();
const config = await buildConfig(args);
console.log("");
console.log(
`${c.cyan}${c.bold}Agent Canvas Static-frontend Development Stack${c.reset}`,
);
console.log("");
// Setup phase (1/3)
checkPrerequisites();
// Ensure isolated state dirs (same as dev-with-automation).
const { mkdirSync } = await import("node:fs");
for (const dir of [
config.stateDir,
join(config.stateDir, "tmux"),
join(config.stateDir, "conversations"),
join(config.stateDir, "workspaces"),
join(config.stateDir, "bash_events"),
join(config.stateDir, "storage"),
]) {
mkdirSync(dir, { recursive: true });
}
// Build phase (2/3): block until the SPA is ready to serve.
buildFrontend(config, args);
// Service phase (3/3)
logStep("3/3", "Starting services...");
// The agent-server skip-loads any conversation whose `owner_lease.json`
// is held by a different `owner_instance_id` and not yet expired (45 s
// TTL). If a previous agent-server (e.g. from `npm run dev`) was killed
// ungracefully — or we restart faster than the lease TTL — every
// conversation gets hidden until those stale leases age out, which
// looks like "the new agent-server doesn't inherit my conversations".
// Bail out if a live agent-server is already bound to our port (we'd
// collide anyway), otherwise unlink the stale leases so the new server
// can claim ownership immediately.
if (await isPortBusy(config.agentServerPort)) {
logError(
`Port ${config.agentServerPort} is already in use — another ` +
`agent-server is running. Stop it (e.g. quit \`npm run dev\`) ` +
`before running dev:static.`,
);
process.exit(1);
}
const conversationsPath = join(config.stateDir, "conversations");
const cleared = releaseStaleConversationLeases(conversationsPath);
if (cleared > 0) {
logService(
"agent-server",
`Released ${cleared} stale conversation lease(s) so the new ` +
`agent-server can resume ownership.`,
c.dim,
);
}
startAgentServer(config);
await waitForService(
"agent-server",
`http://localhost:${config.agentServerPort}/server_info`,
);
startAutomationBackend(config);
startStaticServer(config);
await delay(2000);
startIngress(config);
await delay(1000);
printBanner(config);
}
// ═══════════════════════════════════════════════════════════════════════════
// Exports for testing
// ═══════════════════════════════════════════════════════════════════════════
export { buildFrontend, startStaticServer };
// ═══════════════════════════════════════════════════════════════════════════
// Main entry point (only when run directly, not when imported)
// ═══════════════════════════════════════════════════════════════════════════
const isMainModule =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainModule) {
main().catch((err) => {
logError(`Fatal error: ${err.message}`);
if (err.stack) {
console.error(c.dim + err.stack + c.reset);
}
process.exit(1);
});
}