openclaw/src/gateway/server-web-app.ts
kumarabhirup dbddde9477
Web app: switch to Next.js standalone build for npm packaging
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>
2026-02-12 19:03:01 -08:00

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);
});
}