From dbddde947744bdc6a209f5c8c8cecb17ada83dd0 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 12 Feb 2026 19:03:01 -0800 Subject: [PATCH] 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 --- .../workspace/workspace-sidebar.tsx | 2 +- apps/web/next.config.ts | 25 +- package.json | 11 +- src/gateway/server-web-app.test.ts | 372 ++++++++++++++++++ src/gateway/server-web-app.ts | 224 ++++++++--- 5 files changed, 552 insertions(+), 82 deletions(-) create mode 100644 src/gateway/server-web-app.test.ts diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index c4b828369a5..5b5776eabdb 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -164,7 +164,7 @@ export function WorkspaceSidebar({ color: "var(--color-text-muted)", }} > - Dench CRM + Ironclaw diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 219381a48c7..4ebd2876ceb 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,25 +1,22 @@ import type { NextConfig } from "next"; +import path from "node:path"; const nextConfig: NextConfig = { + // Produce a self-contained standalone build so npm global installs + // can run the web app with `node server.js` — no npm install or + // next build required at runtime. + output: "standalone", + + // Required for pnpm monorepos: trace dependencies from the workspace + // root so the standalone build bundles its own node_modules correctly + // instead of resolving through pnpm's virtual store symlinks. + outputFileTracingRoot: path.join(import.meta.dirname, "..", ".."), + // Allow long-running API routes for agent streaming serverExternalPackages: [], // Transpile ESM-only packages so webpack can bundle them transpilePackages: ["react-markdown", "remark-gfm"], - - // Ensure Node.js built-ins work correctly - webpack: (config, { isServer }) => { - if (isServer) { - // Don't attempt to bundle Node.js built-ins - config.externals = config.externals || []; - config.externals.push({ - "node:child_process": "commonjs node:child_process", - "node:path": "commonjs node:path", - "node:readline": "commonjs node:readline", - }); - } - return config; - }, }; export default nextConfig; diff --git a/package.json b/package.json index 6bbf836dd64..cf0b8ea9a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ironclaw", - "version": "2026.2.10-1.2", + "version": "2026.2.10-1.5", "description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management", "keywords": [], "license": "MIT", @@ -9,7 +9,9 @@ "ironclaw": "openclaw.mjs" }, "files": [ - "apps/web/", + "apps/web/.next/standalone/", + "apps/web/.next/static/", + "apps/web/public/", "assets/", "CHANGELOG.md", "dist/", @@ -73,7 +75,7 @@ "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", - "prepack": "pnpm build && pnpm ui:build", + "prepack": "pnpm build && pnpm ui:build && pnpm web:build && pnpm web:prepack", "prepare": "command -v git >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", @@ -110,7 +112,8 @@ "ui:install": "node scripts/ui.js install", "web:build": "pnpm --dir apps/web build", "web:dev": "pnpm --dir apps/web dev", - "web:install": "pnpm --dir apps/web install" + "web:install": "pnpm --dir apps/web install", + "web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static" }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", diff --git a/src/gateway/server-web-app.test.ts b/src/gateway/server-web-app.test.ts new file mode 100644 index 00000000000..b700c0acade --- /dev/null +++ b/src/gateway/server-web-app.test.ts @@ -0,0 +1,372 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +// Mock child_process.spawn so we don't actually start processes. +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +describe("server-web-app", () => { + let existsSyncSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + existsSyncSpy = vi.spyOn(fs, "existsSync"); + delete process.env.OPENCLAW_SKIP_WEB_APP; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── resolveStandaloneServerJs ────────────────────────────────────────── + + describe("resolveStandaloneServerJs", () => { + it("returns the correct monorepo-nested path for standalone server.js", async () => { + const { resolveStandaloneServerJs } = await import("./server-web-app.js"); + const webAppDir = "/pkg/apps/web"; + expect(resolveStandaloneServerJs(webAppDir)).toBe( + path.join("/pkg/apps/web/.next/standalone/apps/web/server.js"), + ); + }); + }); + + // ── hasStandaloneBuild ────────────────────────────────────────────────── + + describe("hasStandaloneBuild", () => { + it("returns true when standalone server.js exists", async () => { + const { hasStandaloneBuild, resolveStandaloneServerJs } = await import("./server-web-app.js"); + const webAppDir = "/pkg/apps/web"; + existsSyncSpy.mockImplementation((p) => { + return String(p) === resolveStandaloneServerJs(webAppDir); + }); + expect(hasStandaloneBuild(webAppDir)).toBe(true); + }); + + it("returns false when standalone server.js is missing", async () => { + const { hasStandaloneBuild } = await import("./server-web-app.js"); + existsSyncSpy.mockReturnValue(false); + expect(hasStandaloneBuild("/pkg/apps/web")).toBe(false); + }); + }); + + // ── hasLegacyNextBuild ────────────────────────────────────────────────── + + describe("hasLegacyNextBuild", () => { + it("returns true when .next/BUILD_ID exists", async () => { + const { hasLegacyNextBuild } = await import("./server-web-app.js"); + existsSyncSpy.mockImplementation((p) => { + return String(p).endsWith(path.join(".next", "BUILD_ID")); + }); + expect(hasLegacyNextBuild("/pkg/apps/web")).toBe(true); + }); + + it("returns false when .next/BUILD_ID is missing", async () => { + const { hasLegacyNextBuild } = await import("./server-web-app.js"); + existsSyncSpy.mockReturnValue(false); + expect(hasLegacyNextBuild("/pkg/apps/web")).toBe(false); + }); + }); + + // ── isInWorkspace ────────────────────────────────────────────────────── + + describe("isInWorkspace", () => { + it("returns true when pnpm-workspace.yaml exists at root", async () => { + const { isInWorkspace } = await import("./server-web-app.js"); + existsSyncSpy.mockImplementation((p) => { + return String(p).endsWith("pnpm-workspace.yaml"); + }); + expect(isInWorkspace("/proj/apps/web")).toBe(true); + }); + + it("returns false when pnpm-workspace.yaml is missing (global install)", async () => { + const { isInWorkspace } = await import("./server-web-app.js"); + existsSyncSpy.mockReturnValue(false); + expect(isInWorkspace("/usr/lib/node_modules/ironclaw/apps/web")).toBe(false); + }); + }); + + // ── ensureWebAppBuilt ────────────────────────────────────────────────── + + describe("ensureWebAppBuilt", () => { + const makeRuntime = (): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as unknown as (code?: number) => never, + }); + + it("returns ok when OPENCLAW_SKIP_WEB_APP is set", async () => { + process.env.OPENCLAW_SKIP_WEB_APP = "1"; + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + const result = await ensureWebAppBuilt(makeRuntime()); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns ok when web app is explicitly disabled", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + const result = await ensureWebAppBuilt(makeRuntime(), { + webAppConfig: { enabled: false }, + }); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns ok when dev mode is enabled (no pre-build needed)", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + const result = await ensureWebAppBuilt(makeRuntime(), { + webAppConfig: { dev: true }, + }); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns ok when apps/web directory is not found (global install without web)", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + existsSyncSpy.mockReturnValue(false); + const result = await ensureWebAppBuilt(makeRuntime()); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns ok when standalone build exists", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + const result = await ensureWebAppBuilt(makeRuntime()); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns ok when legacy .next/BUILD_ID exists (dev workspace)", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.endsWith(path.join(".next", "BUILD_ID"))) { + return true; + } + return false; + }); + const result = await ensureWebAppBuilt(makeRuntime()); + expect(result).toEqual({ ok: true, built: false }); + }); + + it("returns error for global install when no build found", async () => { + const { ensureWebAppBuilt } = await import("./server-web-app.js"); + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + // Only the package.json exists — no build, no workspace. + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + return false; + }); + const result = await ensureWebAppBuilt(makeRuntime()); + expect(result.ok).toBe(false); + expect(result.built).toBe(false); + expect(result.message).toContain("standalone build not found"); + }); + }); + + // ── startWebAppIfEnabled ─────────────────────────────────────────────── + + describe("startWebAppIfEnabled", () => { + const makeLog = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }); + + function mockChildProcess() { + const events: Record void)[]> = {}; + const child = { + exitCode: null, + killed: false, + pid: 12345, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + events[event] = events[event] || []; + events[event].push(cb); + }), + kill: vi.fn(), + _emit: (event: string, ...args: unknown[]) => { + for (const cb of events[event] || []) { + cb(...args); + } + }, + }; + vi.mocked(spawn).mockReturnValue(child as unknown as ChildProcess); + return child; + } + + it("returns null when OPENCLAW_SKIP_WEB_APP is set", async () => { + process.env.OPENCLAW_SKIP_WEB_APP = "1"; + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + const result = await startWebAppIfEnabled({ enabled: true }, makeLog()); + expect(result).toBeNull(); + }); + + it("returns null when config is undefined", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + const result = await startWebAppIfEnabled(undefined, makeLog()); + expect(result).toBeNull(); + }); + + it("returns null when web app is disabled", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + const result = await startWebAppIfEnabled({ enabled: false }, makeLog()); + expect(result).toBeNull(); + }); + + it("returns null when apps/web directory not found", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + existsSyncSpy.mockReturnValue(false); + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true }, log); + expect(result).toBeNull(); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("apps/web directory not found"), + ); + }); + + it("starts standalone server.js in production mode", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true, port: 4000 }, log); + + expect(result).not.toBeNull(); + expect(result!.port).toBe(4000); + expect(spawn).toHaveBeenCalledWith( + "node", + [expect.stringContaining("server.js")], + expect.objectContaining({ + stdio: "pipe", + env: expect.objectContaining({ PORT: "4000", HOSTNAME: "0.0.0.0" }), + }), + ); + // Should NOT try to install deps or build. + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("standalone")); + expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("installing")); + expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("building")); + }); + + it("falls back to legacy next start when BUILD_ID exists but no standalone", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + // Legacy BUILD_ID exists. + if (s.endsWith(path.join(".next", "BUILD_ID"))) { + return true; + } + // next is installed (for ensureDevDepsInstalled check). + if (s.endsWith(path.join("node_modules", "next", "package.json"))) { + return true; + } + return false; + }); + + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true }, log); + + expect(result).not.toBeNull(); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("falling back to legacy next start"), + ); + }); + + it("returns null with error for global install when no build exists", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + // Only package.json exists — no builds, no workspace. + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + return false; + }); + + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true }, log); + + expect(result).toBeNull(); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining("standalone build not found")); + }); + + it("uses default port when not specified", async () => { + const { startWebAppIfEnabled, DEFAULT_WEB_APP_PORT } = await import("./server-web-app.js"); + mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + + const result = await startWebAppIfEnabled({ enabled: true }, makeLog()); + expect(result!.port).toBe(DEFAULT_WEB_APP_PORT); + }); + + it("stop() sends SIGTERM then resolves on exit", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + const child = mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + + const result = await startWebAppIfEnabled({ enabled: true }, makeLog()); + expect(result).not.toBeNull(); + + // Simulate: process hasn't exited yet. + const stopPromise = result!.stop(); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + // Simulate the exit event. + child._emit("exit", 0, null); + await stopPromise; + }); + }); +}); diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts index 883b3f316b4..bbf367ab122 100644 --- a/src/gateway/server-web-app.ts +++ b/src/gateway/server-web-app.ts @@ -18,13 +18,17 @@ export type WebAppHandle = { * Resolve the `apps/web` directory relative to the package root. * Walks up from the current module until we find `apps/web/package.json`. */ -function resolveWebAppDir(): string | null { +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", "package.json"); - if (fs.existsSync(candidate)) { - return path.join(dir, "apps", "web"); + 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) { @@ -35,11 +39,48 @@ function resolveWebAppDir(): string | null { return null; } -/** Check whether a Next.js production build exists. */ -function hasNextBuild(webAppDir: string): boolean { +/** + * 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 = { @@ -49,12 +90,18 @@ export type EnsureWebAppBuiltResult = { }; /** - * Pre-build the Next.js web app so the gateway can start it immediately. + * Verify the Next.js web app is ready to serve. * - * Call this before installing/starting the gateway daemon so the first - * gateway boot doesn't block on `next build`. Skips silently when the - * web app feature is disabled, already built, or not applicable (e.g. - * global npm install without `apps/web`). + * 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, @@ -73,53 +120,74 @@ export async function ensureWebAppBuilt( const webAppDir = resolveWebAppDir(); if (!webAppDir) { - // No apps/web directory (e.g. global install) — nothing to build. + // No apps/web directory — nothing to verify. return { ok: true, built: false }; } - if (hasNextBuild(webAppDir)) { + // Standalone build ships with the npm package; just verify it exists. + if (hasStandaloneBuild(webAppDir)) { return { ok: true, built: false }; } - const log = { - info: (msg: string) => runtime.log(msg), - warn: (msg: string) => runtime.error(msg), - }; + // 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 }; + } - try { - await ensureDepsInstalled(webAppDir, log); - runtime.log("Web app not built; building for production (next build)…"); - await runCommand("node", [resolveNextBin(webAppDir), "build"], webAppDir, log); - } catch (err) { + // 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 pre-build failed: ${err instanceof Error ? err.message : String(err)}`, + message: "Web app build completed but no build output found.", }; } - if (!hasNextBuild(webAppDir)) { - return { - ok: false, - built: true, - message: "Web app build completed but .next/BUILD_ID is still missing.", - }; - } - - return { ok: true, built: true }; + // 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. * - * - Installs dependencies (`npm install`) if `node_modules/` is missing. - * - Builds (`next build`) on first start when no `.next/BUILD_ID` exists. - * - On subsequent gateway starts/restarts, reuses the existing build. - * - Returns a handle whose `stop()` kills the running server. + * 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. * - * Child processes (dep install, build, server) inherit the gateway's - * process group, so they are also terminated when the gateway exits - * (e.g. Ctrl-C). + * 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, @@ -145,7 +213,7 @@ export async function startWebAppIfEnabled( if (devMode) { // Dev mode: ensure deps, then `next dev`. - await ensureDepsInstalled(webAppDir, log); + await ensureDevDepsInstalled(webAppDir, log); log.info(`starting web app (dev) on port ${port}…`); child = spawn("node", [resolveNextBin(webAppDir), "dev", "--port", String(port)], { cwd: webAppDir, @@ -153,22 +221,55 @@ export async function startWebAppIfEnabled( env: { ...process.env, PORT: String(port) }, }); } else { - // Production: install deps if needed, build if needed, then start. - await ensureDepsInstalled(webAppDir, log); + // Production: prefer standalone, fall back to legacy, then workspace build. + const serverJs = resolveStandaloneServerJs(webAppDir); - if (!hasNextBuild(webAppDir)) { - log.info("building web app for production (first run)…"); + 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); - } else { - log.info("existing web app build found — skipping build"); - } - 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) }, - }); + // 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. @@ -223,29 +324,26 @@ export async function startWebAppIfEnabled( /** * Resolve the local `next` CLI entry script from apps/web/node_modules. - * - * Using `npx next` is fragile in global installs (pnpm, npm) because npx - * walks up the node_modules tree and may hit a broken pnpm virtual-store - * symlink in the parent package. Resolving the local binary directly avoids - * this issue entirely. + * 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"); } -async function ensureDepsInstalled( +/** + * 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 { - // Use `next` as a sentinel — the mere existence of `node_modules/` is not - // enough (a pnpm workspace may create the directory without all packages). 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 so hoisted - // deps resolve correctly. Outside a workspace (npm global install), use npm. + // 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"));