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:
parent
d68b9350c6
commit
dbddde9477
@ -164,7 +164,7 @@ export function WorkspaceSidebar({
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Dench CRM
|
||||
Ironclaw
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
11
package.json
11
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",
|
||||
|
||||
372
src/gateway/server-web-app.test.ts
Normal file
372
src/gateway/server-web-app.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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"));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user