Files
2026-07-13 13:39:12 +08:00

211 lines
9.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Backend-only build helper.
*
* When `OMNIROUTE_BUILD_BACKEND_ONLY=1` (or `OMNIROUTE_BUILD_PROFILE=backend`) is set,
* `build-next-isolated.mjs` calls `stubDashboardPages()` BEFORE `next build` and
* `restoreDashboardPages()` in a `finally` afterward.
*
* WHY: OmniRoute embedders that only consume the HTTP API (`/api/*`, `/v1/*`, `/v1beta/*`)
* — e.g. the VibeProxy desktop app, headless self-hosters, CI that only needs the router —
* do NOT need the Next.js dashboard UI. Building it dominates `next build`: the ~126 leaf
* pages pull in heavy client vendor chunks (recharts, monaco-editor, @xyflow, mermaid,
* @lobehub/icons), the static-generation pass renders every route, and React Server Actions
* generate a client-entry manifest. Replacing every App-Router UI file (page/layout/template/
* loading/error/not-found/default) with a trivial server stub removes the client graph, the
* prerender pass, AND all `"use server"` actions, while leaving EVERY `route.ts` (API) handler,
* `middleware`, and metadata route (sitemap/robots/manifest/icon/...) fully intact. The
* resulting standalone `server.js` serves the complete backend API; the dashboard renders
* nothing.
*
* WHY STUB LAYOUTS TOO (not just pages): Next's FlightClientEntryPlugin builds a per-route
* Server-Actions manifest. Stubbing only the pages while leaving layouts (which import client
* providers and inline `"use server"` actions) leaves actions registered whose page-level
* client entry no longer exists — `createActionAssets` then dereferences an undefined module
* map ("Cannot read properties of undefined (reading 'server')"). Stubbing the whole UI tree
* removes every action and client reference, so the plugin has nothing to reconcile.
*
* SAFETY: these files are git-tracked, so a hard kill is recoverable via `git checkout -- src/app`.
* The caller also restores in a `finally` block and on SIGINT/SIGTERM. Stubs carry a marker so
* the operation is idempotent and detectable.
*/
import fs from "node:fs";
import path from "node:path";
export const BACKEND_ONLY_STUB_MARKER =
"/* omniroute:backend-only-stub (auto-restored after build) */";
const HEADER = `${BACKEND_ONLY_STUB_MARKER}\n`;
// Leaf page → force-dynamic (never prerendered) server component returning null.
const PAGE_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function BackendOnlyPageStub() {\n return null;\n}\n`;
// Root layout MUST render <html>/<body>. Minimal server component, no client imports.
const ROOT_LAYOUT_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function RootLayout({ children }) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n );\n}\n`;
// Non-root layout / template → pass children through unchanged.
const PASSTHROUGH_STUB = `${HEADER}export default function BackendOnlyPassthroughStub({ children }) {\n return children;\n}\n`;
// loading / default / not-found → render nothing.
const NULL_STUB = `${HEADER}export default function BackendOnlyNullStub() {\n return null;\n}\n`;
// Error boundaries must be Client Components in Next; a no-op client stub carries no action.
const ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyErrorStub() {\n return null;\n}\n`;
// global-error replaces the root layout on a root error, so it must render <html>/<body>.
const GLOBAL_ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyGlobalErrorStub() {\n return (\n <html>\n <body></body>\n </html>\n );\n}\n`;
const UI_BASENAME_RE = /^(page|layout|template|loading|error|global-error|not-found|default)\.(tsx|jsx|ts|js)$/;
const ROUTE_FILE_RE = /[\\/]route\.(ts|js|tsx|jsx)$/;
/**
* Strip a leading `"use server"` module directive. Some OmniRoute API Route Handlers
* (`src/app/api/**\/route.ts`) carry a top-level `"use server"` — which registers the module
* as a React Server-Actions provider. Once the dashboard pages that import those exports as
* actions are stubbed away, Next's FlightClientEntryPlugin still has the action registered but
* no client entry to bind it to, and `createActionAssets` dereferences an undefined module map
* ("Cannot read properties of undefined (reading 'server')"). Removing the directive turns the
* file back into a plain Route Handler — the GET/POST HTTP endpoint is UNCHANGED (route handlers
* are server-side regardless of the directive) — so the API keeps working while the phantom
* action registration disappears. The directive is only removed when it is the first real
* statement (comments may precede it); a `"use server"` string appearing later is untouched.
*/
function stripLeadingUseServer(src) {
const lines = src.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim().replace(/^\uFEFF/, "");
if (t === "") continue;
if (/^["']use server["'];?$/.test(t)) {
lines.splice(i, 1);
return { changed: true, src: lines.join("\n") };
}
if (t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
break; // first real statement is not the directive
}
return { changed: false, src };
}
/** True when the current build should skip the dashboard frontend. */
export function isBackendOnlyBuild(env = process.env) {
return env.OMNIROUTE_BUILD_BACKEND_ONLY === "1" || env.OMNIROUTE_BUILD_PROFILE === "backend";
}
function walkFiles(dir, out = []) {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkFiles(full, out);
else out.push(full);
}
return out;
}
/** Pick the stub source for an App-Router UI file (null = don't stub this file). */
function stubFor(file, appDir) {
const base = path.basename(file);
const m = UI_BASENAME_RE.exec(base);
if (!m) return null;
const kind = m[1];
const relDir = path.relative(appDir, path.dirname(file));
const isRoot = relDir === "" || relDir === ".";
switch (kind) {
case "page":
return PAGE_STUB;
case "layout":
return isRoot ? ROOT_LAYOUT_STUB : PASSTHROUGH_STUB;
case "template":
return PASSTHROUGH_STUB;
case "loading":
case "default":
case "not-found":
return NULL_STUB;
case "error":
return ERROR_STUB;
case "global-error":
return GLOBAL_ERROR_STUB;
default:
return null;
}
}
/**
* Replace every App-Router UI file under src/app with a trivial stub.
* @returns {{file:string, original:string}[]} stubbed files + their original contents.
*/
export function stubDashboardPages(rootDir = process.cwd(), log = console) {
const appDir = path.join(rootDir, "src", "app");
if (!fs.existsSync(appDir)) {
log.warn?.("[backend-only] src/app not found — nothing to stub");
return [];
}
const stubbed = [];
for (const file of walkFiles(appDir)) {
const stub = stubFor(file, appDir);
if (stub) {
let original;
try {
original = fs.readFileSync(file, "utf8");
} catch {
continue;
}
if (original.includes(BACKEND_ONLY_STUB_MARKER)) continue; // idempotent
try {
fs.writeFileSync(file, stub, "utf8");
stubbed.push({ file, original });
} catch (err) {
log.warn?.(`[backend-only] Could not stub ${file}: ${err?.message || err}`);
}
continue;
}
// Route Handlers with a leading "use server" directive: strip the directive so the module
// is no longer registered as a Server-Actions provider (the HTTP endpoint is unchanged).
if (ROUTE_FILE_RE.test(file)) {
let original;
try {
original = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const { changed, src } = stripLeadingUseServer(original);
if (!changed) continue;
try {
fs.writeFileSync(file, src, "utf8");
stubbed.push({ file, original });
} catch (err) {
log.warn?.(`[backend-only] Could not de-action ${file}: ${err?.message || err}`);
}
}
}
const uiCount = stubbed.filter((e) => ROUTE_FILE_RE.test(e.file) === false).length;
log.log?.(
`[backend-only] Stubbed ${uiCount} App-Router UI file(s) + de-actioned ${
stubbed.length - uiCount
} route handler(s); route.ts HTTP endpoints left intact`
);
return stubbed;
}
/** Restore the original contents of every stubbed file. Best-effort; logs failures. */
export function restoreDashboardPages(stubbed, log = console) {
if (!Array.isArray(stubbed) || stubbed.length === 0) return;
let restored = 0;
for (const entry of stubbed) {
if (!entry) continue;
try {
fs.writeFileSync(entry.file, entry.original, "utf8");
restored += 1;
} catch (err) {
log.error?.(
`[backend-only] FAILED to restore ${entry.file}: ${err?.message || err} — ` +
`run \`git checkout -- ${entry.file}\` to recover`
);
}
}
log.log?.(`[backend-only] Restored ${restored}/${stubbed.length} App-Router UI file(s)`);
}