Files
OpenHands/scripts/dev-static.mjs
T
Robert Brennanandopenhands 9c3b936d16 Add npm run dev:static for offline / high-latency development (#168)
Mirrors the dev:automation backend stack (agent-server + automation +
ingress) but serves a production frontend build through a small static
server instead of Vite. Designed for use over flaky / high-RTT links
where Vite's ~1000 ESM module fetches make full reloads painfully slow:
hashed assets are now sent with public/immutable cache headers, so an
SPA reload is ~1 round-trip (304 on index.html) and zero asset fetches.

scripts/static-server.mjs: combined static-file server + reverse proxy.
A drop-in for sirv-cli that additionally proxies the same prefixes Vite
proxies in dev (/api, /api/automation, /sockets, /server_info, /alive,
/health, /ready) so hitting :3001 directly behaves like Vite's dev
server — without it, sirv-cli's --single fallback turns /server_info
into the SPA shell whenever a tunnel exposes the static port instead of
the ingress port. Caches /assets/* immutable, index.html no-cache,
weak ETags.

scripts/dev-static.mjs: orchestrator that builds the frontend, then
spawns agent-server, automation, static-server, and the existing
ingress with the same route table as dev-with-automation.

scripts/dev-safe.mjs: add isPortBusy() and
releaseStaleConversationLeases() helpers. The agent-server tags each
conversation directory with an owner_lease.json keyed to a per-process
owner_instance_id (45 s TTL, heartbeat-renewed) and skip-loads any
conversation whose lease is held by a different instance. If the
previous agent-server died ungracefully — or you restart inside the
TTL window — every existing conversation becomes invisible to the new
instance until the leases age out. dev:static now port-checks for a
live agent-server (aborts on conflict), then unlinks stale leases so
conversations created by npm run dev are immediately visible.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-08 17:29:27 +07: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.
#!/usr/bin/env node
/**
* 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, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } 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) {
try {
execSync(`command -v ${cmd}`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
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 = 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 = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
main().catch((err) => {
logError(`Fatal error: ${err.message}`);
if (err.stack) {
console.error(c.dim + err.stack + c.reset);
}
process.exit(1);
});
}