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>
This commit is contained in:
kumarabhirup 2026-02-12 19:03:01 -08:00
parent d68b9350c6
commit dbddde9477
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 552 additions and 82 deletions

View File

@ -164,7 +164,7 @@ export function WorkspaceSidebar({
color: "var(--color-text-muted)",
}}
>
Dench CRM
Ironclaw
</div>
</div>
</div>

View File

@ -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;

View File

@ -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",

View File

@ -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<typeof vi.spyOn>;
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<string, ((...args: unknown[]) => 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;
});
});
});

View File

@ -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<void> {
// 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"));