agent-orchestrator/packages/web/server/start-all.ts

198 lines
5.7 KiB
TypeScript

/**
* Production entry point — starts Next.js + terminal servers.
* Used by `ao start` when running from an npm install (no monorepo).
* Replaces the dev-only `concurrently` setup.
*/
import { type ChildProcess } from "node:child_process";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import {
isWindows,
killProcessTree,
markDaemonShutdownHandlerInstalled,
spawnManagedDaemonChild,
} from "@aoagents/ao-core";
import { ensureRemoteAuthCredentials, ensureRemoteWsTokenSecret } from "./remote-auth.js";
import { proxyTerminalUpgrade } from "./terminal-proxy.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
type NextApp = {
prepare: () => Promise<void>;
getRequestHandler: () => (req: IncomingMessage, res: ServerResponse) => Promise<void> | void;
};
const next = require("next") as (options: {
dev: boolean;
dir: string;
hostname: string;
port: number;
}) => NextApp;
// Resolve paths relative to the package root (one level up from dist-server/)
const pkgRoot = resolve(__dirname, "..");
const children: ChildProcess[] = [];
markDaemonShutdownHandlerInstalled();
let nextServer: Server | null = null;
let shuttingDown = false;
ensureRemoteAuthCredentials();
ensureRemoteWsTokenSecret();
function log(label: string, msg: string): void {
process.stdout.write(`[${label}] ${msg}\n`);
}
function spawnProcess(
label: string,
command: string,
args: string[],
opts?: { restart?: boolean; maxRestarts?: number },
): ChildProcess {
let restarts = 0;
const maxRestarts = opts?.maxRestarts ?? 3;
let slotIndex = -1;
function launch(): ChildProcess {
const child = spawnManagedDaemonChild(`dashboard:${label}`, command, args, {
cwd: pkgRoot,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
detached: !isWindows(),
});
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n").filter(Boolean)) {
log(label, line);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n").filter(Boolean)) {
log(label, line);
}
});
child.on("exit", (code) => {
log(label, `exited with code ${code}`);
if (!shuttingDown && opts?.restart && code !== 0 && restarts < maxRestarts) {
restarts++;
log(label, `restarting (attempt ${restarts}/${maxRestarts})`);
const replacement = launch();
// Replace in-place — slot was assigned on first push
children[slotIndex] = replacement;
}
});
// Only push on first launch; restarts replace the existing slot
if (slotIndex === -1) {
slotIndex = children.length;
children.push(child);
}
return child;
}
return launch();
}
const port = process.env["PORT"] || "3000";
const hostname = process.env["HOST"] || "0.0.0.0";
process.env["AO_TRUST_REMOTE_ADDRESS_HEADER"] = "1";
const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1";
// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is
// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy
// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to
// direct-terminal-ws. Default off — Next.js stays on PORT directly.
const NEXT_INTERNAL_OFFSET = 1000;
const nextPort = pathBasedMux
? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET))
: port;
if (pathBasedMux) {
// Surface the internal port to the child so it doesn't have to re-derive
// the offset; pin it explicitly.
process.env["NEXT_INTERNAL_PORT"] = nextPort;
spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]);
}
// Start direct terminal WebSocket server (auto-restart on crash)
spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], {
restart: true,
});
async function startNextServer(): Promise<void> {
const app = next({ dev: false, dir: pkgRoot, hostname, port: Number.parseInt(nextPort, 10) });
const handle = app.getRequestHandler();
await app.prepare();
nextServer = createServer((req: IncomingMessage, res: ServerResponse) => {
req.headers["x-ao-remote-address"] = req.socket.remoteAddress ?? "";
void handle(req, res);
});
nextServer.on("upgrade", (request, socket, head) => {
if (!proxyTerminalUpgrade(request, socket, head)) {
socket.destroy();
}
});
nextServer.listen(Number.parseInt(nextPort, 10), hostname, () => {
log("next", `ready on http://${hostname}:${nextPort}`);
});
}
startNextServer().catch((err: unknown) => {
log("next", `failed to start: ${err instanceof Error ? err.message : String(err)}`);
cleanup();
});
function cleanup(): void {
if (shuttingDown) return;
shuttingDown = true;
let alive = children.length;
if (alive === 0) {
nextServer?.close();
process.exit(0);
return;
}
nextServer?.close();
// Force exit after 5s if children don't exit cleanly
const forceTimer = setTimeout(() => {
log("start-all", "Children did not exit in time, forcing shutdown");
process.exit(1);
}, 5000);
forceTimer.unref();
for (const child of children) {
child.on("exit", () => {
alive--;
if (alive <= 0) {
clearTimeout(forceTimer);
process.exit(0);
}
});
const pid = child.pid;
if (pid) {
void killProcessTree(pid, "SIGTERM").catch(() => {
child.kill("SIGTERM");
});
} else {
child.kill("SIGTERM");
}
}
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);