Ship a self-contained standalone server with the npm package so `npm i -g ironclaw` can serve the web UI without runtime `npm install` or `next build`. This eliminates the fragile first-boot build step and cuts the cold-start time for the gateway web app. Changes: - next.config.ts: enable `output: "standalone"` and set `outputFileTracingRoot` to the monorepo root so pnpm workspace deps are traced correctly. Remove the now-unnecessary manual webpack externals for Node.js built-ins. - package.json: update `files` to ship only the standalone build output, static assets, and public dir (instead of the entire `apps/web/` tree). Add `web:build` and `web:prepack` to the `prepack` script so the standalone server is built and its static/public assets are copied into place before publish. Bump version to 2026.2.10-1.5. - server-web-app.ts: rewrite the web app lifecycle to prefer the pre-built standalone `server.js` in production. Add `resolveStandaloneServerJs`, `hasStandaloneBuild`, `hasLegacyNextBuild`, and `isInWorkspace` helpers. In dev workspaces, fall back to building on-the-fly or legacy `next start`. Export key functions for testability. - server-web-app.test.ts: add comprehensive unit tests covering path resolution, standalone/legacy build detection, ensureWebAppBuilt scenarios (skip, disabled, dev, standalone, legacy, missing), startWebAppIfEnabled (skip, disabled, null config, missing dir, standalone start, missing build error, default port, graceful stop). - workspace-sidebar.tsx: update sidebar branding to "Ironclaw". Published as ironclaw@2026.2.10-1.5. Co-authored-by: Cursor <cursoragent@cursor.com>
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import type { ChildProcess } from "node:child_process";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { GatewayWebAppConfig } from "../config/types.gateway.js";
|
|
import { isTruthyEnvValue } from "../infra/env.js";
|
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|
|
|
export const DEFAULT_WEB_APP_PORT = 3100;
|
|
|
|
export type WebAppHandle = {
|
|
port: number;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Resolve the `apps/web` directory relative to the package root.
|
|
* Walks up from the current module until we find `apps/web/package.json`.
|
|
*/
|
|
export function resolveWebAppDir(): string | null {
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
let dir = path.dirname(__filename);
|
|
for (let i = 0; i < 10; i++) {
|
|
const candidate = path.join(dir, "apps", "web");
|
|
// Accept either package.json (dev workspace) or .next/standalone (production).
|
|
if (
|
|
fs.existsSync(path.join(candidate, "package.json")) ||
|
|
fs.existsSync(path.join(candidate, ".next", "standalone"))
|
|
) {
|
|
return candidate;
|
|
}
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) {
|
|
break;
|
|
}
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check whether a pre-built Next.js standalone server exists.
|
|
*
|
|
* The standalone build is produced by `next build` with `output: "standalone"`
|
|
* in `next.config.ts` and ships with the npm package. It includes a
|
|
* self-contained `server.js` that can run without `node_modules` or `next`.
|
|
*/
|
|
export function hasStandaloneBuild(webAppDir: string): boolean {
|
|
return fs.existsSync(resolveStandaloneServerJs(webAppDir));
|
|
}
|
|
|
|
/**
|
|
* Resolve the standalone server.js path for the web app.
|
|
*
|
|
* With `outputFileTracingRoot` set to the monorepo root (required for
|
|
* pnpm), the standalone output mirrors the repo directory structure.
|
|
* `server.js` lives at `.next/standalone/apps/web/server.js`.
|
|
*/
|
|
export function resolveStandaloneServerJs(webAppDir: string): string {
|
|
return path.join(webAppDir, ".next", "standalone", "apps", "web", "server.js");
|
|
}
|
|
|
|
/**
|
|
* Check whether a classic Next.js production build exists (legacy).
|
|
* Kept for backward compatibility with dev-workspace builds that haven't
|
|
* switched to standalone yet.
|
|
*/
|
|
export function hasLegacyNextBuild(webAppDir: string): boolean {
|
|
return fs.existsSync(path.join(webAppDir, ".next", "BUILD_ID"));
|
|
}
|
|
|
|
/**
|
|
* Detect whether we're running inside a pnpm workspace (dev checkout)
|
|
* vs. a standalone npm/global install. In a workspace, building at
|
|
* runtime is safe because all deps are available. In a global install,
|
|
* runtime builds are fragile and should not be attempted.
|
|
*/
|
|
export function isInWorkspace(webAppDir: string): boolean {
|
|
const rootDir = path.resolve(webAppDir, "..", "..");
|
|
return fs.existsSync(path.join(rootDir, "pnpm-workspace.yaml"));
|
|
}
|
|
|
|
// ── pre-build ────────────────────────────────────────────────────────────────
|
|
|
|
export type EnsureWebAppBuiltResult = {
|
|
ok: boolean;
|
|
built: boolean;
|
|
message?: string;
|
|
};
|
|
|
|
/**
|
|
* Verify the Next.js web app is ready to serve.
|
|
*
|
|
* For production (npm global install): checks that the pre-built standalone
|
|
* server exists. No runtime `npm install` or `next build` is performed —
|
|
* the standalone build ships with the npm package via `prepack`.
|
|
*
|
|
* For dev workspaces: builds the web app if no build exists (safe because
|
|
* all deps are available via the workspace). `next dev` compiles on-the-fly,
|
|
* so no pre-build is needed when `dev` mode is enabled.
|
|
*
|
|
* Skips silently when the web app feature is disabled or `apps/web` is
|
|
* not present.
|
|
*/
|
|
export async function ensureWebAppBuilt(
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
opts?: { webAppConfig?: GatewayWebAppConfig },
|
|
): Promise<EnsureWebAppBuiltResult> {
|
|
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
|
|
return { ok: true, built: false };
|
|
}
|
|
if (opts?.webAppConfig && opts.webAppConfig.enabled === false) {
|
|
return { ok: true, built: false };
|
|
}
|
|
// Dev mode uses `next dev` which compiles on-the-fly — no pre-build needed.
|
|
if (opts?.webAppConfig?.dev) {
|
|
return { ok: true, built: false };
|
|
}
|
|
|
|
const webAppDir = resolveWebAppDir();
|
|
if (!webAppDir) {
|
|
// No apps/web directory — nothing to verify.
|
|
return { ok: true, built: false };
|
|
}
|
|
|
|
// Standalone build ships with the npm package; just verify it exists.
|
|
if (hasStandaloneBuild(webAppDir)) {
|
|
return { ok: true, built: false };
|
|
}
|
|
|
|
// Legacy: accept a classic .next/BUILD_ID build (dev workspace that
|
|
// hasn't been rebuilt with standalone yet).
|
|
if (hasLegacyNextBuild(webAppDir)) {
|
|
return { ok: true, built: false };
|
|
}
|
|
|
|
// In a pnpm workspace, attempt to build — all deps are available.
|
|
if (isInWorkspace(webAppDir)) {
|
|
const log = {
|
|
info: (msg: string) => runtime.log(msg),
|
|
warn: (msg: string) => runtime.error(msg),
|
|
};
|
|
try {
|
|
await ensureDevDepsInstalled(webAppDir, log);
|
|
runtime.log("Web app not built; building for production (next build)…");
|
|
await runCommand("node", [resolveNextBin(webAppDir), "build"], webAppDir, log);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
built: false,
|
|
message: `Web app build failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
|
|
if (hasStandaloneBuild(webAppDir) || hasLegacyNextBuild(webAppDir)) {
|
|
return { ok: true, built: true };
|
|
}
|
|
return {
|
|
ok: false,
|
|
built: false,
|
|
message: "Web app build completed but no build output found.",
|
|
};
|
|
}
|
|
|
|
// Global npm install without a pre-built standalone — nothing we can do.
|
|
return {
|
|
ok: false,
|
|
built: false,
|
|
message:
|
|
"Web app standalone build not found. " +
|
|
"Reinstall the package to get the pre-built web app.",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start the Ironclaw Next.js web app as a child process.
|
|
*
|
|
* Production mode (default):
|
|
* Uses the pre-built standalone server (`node server.js`). No runtime
|
|
* `npm install` or `next build` is needed — the standalone output ships
|
|
* with the npm package.
|
|
*
|
|
* In a dev workspace without a standalone build, falls back to a classic
|
|
* `next start` (with legacy BUILD_ID) or builds on-the-fly.
|
|
*
|
|
* Dev mode (`gateway.webApp.dev: true`):
|
|
* Runs `next dev` from the workspace, installing deps if needed.
|
|
*
|
|
* Returns a handle whose `stop()` kills the running server.
|
|
*/
|
|
export async function startWebAppIfEnabled(
|
|
cfg: GatewayWebAppConfig | undefined,
|
|
log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
|
): Promise<WebAppHandle | null> {
|
|
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
|
|
return null;
|
|
}
|
|
if (!cfg?.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const port = cfg.port ?? DEFAULT_WEB_APP_PORT;
|
|
const devMode = cfg.dev === true;
|
|
|
|
const webAppDir = resolveWebAppDir();
|
|
if (!webAppDir) {
|
|
log.warn("apps/web directory not found — skipping web app");
|
|
return null;
|
|
}
|
|
|
|
let child: ChildProcess;
|
|
|
|
if (devMode) {
|
|
// Dev mode: ensure deps, then `next dev`.
|
|
await ensureDevDepsInstalled(webAppDir, log);
|
|
log.info(`starting web app (dev) on port ${port}…`);
|
|
child = spawn("node", [resolveNextBin(webAppDir), "dev", "--port", String(port)], {
|
|
cwd: webAppDir,
|
|
stdio: "pipe",
|
|
env: { ...process.env, PORT: String(port) },
|
|
});
|
|
} else {
|
|
// Production: prefer standalone, fall back to legacy, then workspace build.
|
|
const serverJs = resolveStandaloneServerJs(webAppDir);
|
|
|
|
if (fs.existsSync(serverJs)) {
|
|
// Standalone build found — just run it (npm global install or post-build).
|
|
log.info(`starting web app (standalone) on port ${port}…`);
|
|
child = spawn("node", [serverJs], {
|
|
cwd: path.dirname(serverJs),
|
|
stdio: "pipe",
|
|
env: { ...process.env, PORT: String(port), HOSTNAME: "0.0.0.0" },
|
|
});
|
|
} else if (hasLegacyNextBuild(webAppDir)) {
|
|
// Legacy build — use `next start` (dev workspace that hasn't rebuilt).
|
|
log.warn("standalone build not found — falling back to legacy next start");
|
|
await ensureDevDepsInstalled(webAppDir, log);
|
|
child = spawn("node", [resolveNextBin(webAppDir), "start", "--port", String(port)], {
|
|
cwd: webAppDir,
|
|
stdio: "pipe",
|
|
env: { ...process.env, PORT: String(port) },
|
|
});
|
|
} else if (isInWorkspace(webAppDir)) {
|
|
// Dev workspace with no build at all — build first, then start.
|
|
log.info("no web app build found — building in workspace…");
|
|
await ensureDevDepsInstalled(webAppDir, log);
|
|
await runCommand("node", [resolveNextBin(webAppDir), "build"], webAppDir, log);
|
|
|
|
// After building, prefer standalone if the config produced it.
|
|
if (fs.existsSync(serverJs)) {
|
|
log.info(`starting web app (standalone) on port ${port}…`);
|
|
child = spawn("node", [serverJs], {
|
|
cwd: path.dirname(serverJs),
|
|
stdio: "pipe",
|
|
env: { ...process.env, PORT: String(port), HOSTNAME: "0.0.0.0" },
|
|
});
|
|
} else {
|
|
log.info(`starting web app (production) on port ${port}…`);
|
|
child = spawn("node", [resolveNextBin(webAppDir), "start", "--port", String(port)], {
|
|
cwd: webAppDir,
|
|
stdio: "pipe",
|
|
env: { ...process.env, PORT: String(port) },
|
|
});
|
|
}
|
|
} else {
|
|
// Global install with no standalone build — nothing we can safely do.
|
|
log.error(
|
|
"web app standalone build not found — reinstall the package to get the pre-built web app",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Forward child stdout/stderr to the gateway log.
|
|
child.stdout?.on("data", (data: Buffer) => {
|
|
for (const line of data.toString().split("\n").filter(Boolean)) {
|
|
log.info(line);
|
|
}
|
|
});
|
|
child.stderr?.on("data", (data: Buffer) => {
|
|
for (const line of data.toString().split("\n").filter(Boolean)) {
|
|
log.warn(line);
|
|
}
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
log.error(`web app process error: ${String(err)}`);
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
if (code !== null && code !== 0) {
|
|
log.warn(`web app exited with code ${code}`);
|
|
} else if (signal) {
|
|
log.info(`web app terminated by signal ${signal}`);
|
|
}
|
|
});
|
|
|
|
log.info(`web app available at http://localhost:${port}`);
|
|
|
|
return {
|
|
port,
|
|
stop: async () => {
|
|
if (child.exitCode === null && !child.killed) {
|
|
child.kill("SIGTERM");
|
|
await new Promise<void>((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode === null && !child.killed) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
resolve();
|
|
}, 5_000);
|
|
child.on("exit", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the local `next` CLI entry script from apps/web/node_modules.
|
|
* Only used in dev/workspace mode — production uses the standalone server.js.
|
|
*/
|
|
function resolveNextBin(webAppDir: string): string {
|
|
return path.join(webAppDir, "node_modules", "next", "dist", "bin", "next");
|
|
}
|
|
|
|
/**
|
|
* Install web app dependencies if needed (dev/workspace mode only).
|
|
* Production standalone builds are self-contained and don't need this.
|
|
*/
|
|
async function ensureDevDepsInstalled(
|
|
webAppDir: string,
|
|
log: { info: (msg: string) => void },
|
|
): Promise<void> {
|
|
const nextPkg = path.join(webAppDir, "node_modules", "next", "package.json");
|
|
if (fs.existsSync(nextPkg)) {
|
|
return;
|
|
}
|
|
|
|
// In a pnpm workspace, run `pnpm install` at the workspace root.
|
|
const rootDir = path.resolve(webAppDir, "..", "..");
|
|
const inWorkspace = fs.existsSync(path.join(rootDir, "pnpm-workspace.yaml"));
|
|
|
|
if (inWorkspace) {
|
|
log.info("installing web app dependencies (workspace)…");
|
|
await runCommand("pnpm", ["install"], rootDir, log);
|
|
} else {
|
|
log.info("installing web app dependencies…");
|
|
await runCommand("npm", ["install", "--legacy-peer-deps"], webAppDir, log);
|
|
}
|
|
}
|
|
|
|
function runCommand(
|
|
cmd: string,
|
|
args: string[],
|
|
cwd: string,
|
|
log?: { info: (msg: string) => void },
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const proc = spawn(cmd, args, { cwd, stdio: "pipe", env: { ...process.env } });
|
|
if (log) {
|
|
proc.stdout?.on("data", (data: Buffer) => {
|
|
for (const line of data.toString().split("\n").filter(Boolean)) {
|
|
log.info(line);
|
|
}
|
|
});
|
|
proc.stderr?.on("data", (data: Buffer) => {
|
|
for (const line of data.toString().split("\n").filter(Boolean)) {
|
|
log.info(line);
|
|
}
|
|
});
|
|
}
|
|
proc.on("close", (code) =>
|
|
code === 0
|
|
? resolve()
|
|
: reject(new Error(`${cmd} ${args.join(" ")} exited with code ${code}`)),
|
|
);
|
|
proc.on("error", reject);
|
|
});
|
|
}
|