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

323 lines
11 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
import {
assembleStandalone,
syncStandaloneNativeAssets as _syncNativeAssets,
syncStandaloneExtraModules as _syncExtraModules,
} from "./assembleStandalone.mjs";
import {
isBackendOnlyBuild,
stubDashboardPages,
restoreDashboardPages,
} from "./backendOnlyPages.mjs";
/**
* Layer 1: `app/` has been renamed to `dist/` and the App-Router collision is gone.
* The only transient paths remaining are `.tmp/wine32` (Wine prefix used by some
* older build tools) and `_tasks` (planning workspace).
*/
const projectRoot = process.cwd();
const distDir = path.resolve(process.env.NEXT_DIST_DIR || ".build/next");
const backupRoot = path.join(os.tmpdir(), `omniroute-build-isolated-${process.pid}-${Date.now()}`);
export function getTransientBuildPaths(rootDir = projectRoot, env = process.env) {
const paths = [
{
label: "local Wine prefix",
sourcePath: path.join(rootDir, ".tmp", "wine32"),
backupPath: path.join(backupRoot, "wine32"),
},
];
if (env.OMNIROUTE_BUILD_MOVE_TASKS === "1") {
paths.push({
label: "task planning workspace",
sourcePath: path.join(rootDir, "_tasks"),
backupPath: path.join(backupRoot, "_tasks"),
});
}
return paths;
}
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
try {
await fsImpl.rename(sourcePath, destinationPath);
} catch (error) {
if (error?.code !== "EXDEV") {
throw error;
}
console.warn(
`[build-next-isolated] EXDEV while moving ${sourcePath} -> ${destinationPath}; falling back to copy/remove`
);
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
preserveTimestamps: true,
force: false,
errorOnExist: true,
});
await fsImpl.rm(sourcePath, { recursive: true, force: true });
}
}
function runNextBuild() {
return new Promise((resolve) => {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
cwd: projectRoot,
stdio: "inherit",
env: resolveNextBuildEnv(process.env),
});
const forward = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", forward);
process.on("SIGTERM", forward);
child.on("exit", (code, signal) => {
process.off("SIGINT", forward);
process.off("SIGTERM", forward);
if (signal) {
resolve({ code: 1, signal });
return;
}
resolve({ code: code ?? 1, signal: null });
});
});
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
// the explicit escape hatch (=0) for bundler-compat regressions.
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
}
export function resolveNextBuildEnv(baseEnv = process.env) {
const env = {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
};
// Raise the Node heap for the spawned `next build`. The webpack production pass
// ("Compiling instrumentation" bundles the whole server graph) is the heaviest
// phase and overflows V8's default ~2 GB ceiling on memory-constrained machines,
// stalling/OOMing local `npm run build` (npm-global installs). #4076/#4104 fixed
// this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path
// was left unprotected. Respect an existing --max-old-space-size (Docker already
// sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override.
if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) {
// Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack
// production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives
// headroom without risk. NOTE: heap size does NOT fix a poisoned scope — if the build
// OOMs/livelocks far above this, check for worktrees/cruft leaking into the tsconfig
// scope (run `npm run check:build-scope`), not for "more heap". See incident 2026-06-25.
const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 8192;
env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim();
}
return env;
}
async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) {
// Use the module-level distDir so NEXT_DIST_DIR is respected
const resolvedDistDir =
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDir, "standalone");
if (!(await exists(standaloneRoot))) return;
const staleStandaloneBackup = path.join(backupRoot, "standalone-stale");
await movePath(standaloneRoot, staleStandaloneBackup, fsImpl);
console.log("[build-next-isolated] Moved stale standalone output out of the build path");
}
export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) {
const resolvedDistDirForPrune =
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDirForPrune, "standalone");
const pruneTargets = [path.join(standaloneRoot, "_tasks")];
for (const targetPath of pruneTargets) {
if (!(await exists(targetPath))) continue;
await fsImpl.rm(targetPath, { recursive: true, force: true });
console.log(
`[build-next-isolated] Pruned standalone artifact: ${path.relative(rootDir, targetPath)}`
);
}
}
export async function syncStandaloneNativeAssets(
rootDir = projectRoot,
fsImpl = fs,
log = console
) {
return _syncNativeAssets(rootDir, fsImpl, log);
}
export async function syncStandaloneExtraModules(
rootDir = projectRoot,
fsImpl = fs,
log = console
) {
return _syncExtraModules(rootDir, fsImpl, log);
}
export async function main() {
const movedPaths = [];
const transientBuildPaths = getTransientBuildPaths();
// Backend-only fast build: replace the dashboard leaf pages with zero-cost stubs so
// `next build` skips the frontend (client vendor chunks + prerender) while keeping every
// API route handler. Restored in `finally` and on SIGINT/SIGTERM (git-recoverable regardless).
let stubbedPages = [];
const restoreStubbedPagesOnce = () => {
if (stubbedPages.length > 0) {
restoreDashboardPages(stubbedPages);
stubbedPages = [];
}
};
const onFatalSignal = (signal) => {
console.warn(`[build-next-isolated] Received ${signal} — restoring stubbed pages before exit`);
restoreStubbedPagesOnce();
process.exit(1);
};
try {
for (const entry of transientBuildPaths) {
if (!(await exists(entry.sourcePath))) continue;
await movePath(entry.sourcePath, entry.backupPath);
movedPaths.push(entry);
}
if (isBackendOnlyBuild()) {
console.log(
"[build-next-isolated] OMNIROUTE_BUILD_BACKEND_ONLY set — building API only (dashboard UI stubbed)"
);
stubbedPages = stubDashboardPages(projectRoot);
process.once("SIGINT", onFatalSignal);
process.once("SIGTERM", onFatalSignal);
}
await resetStandaloneOutput(projectRoot);
const result = await runNextBuild();
const standaloneDir = path.join(distDir, "standalone");
if (result.code === 0 && (await exists(standaloneDir))) {
try {
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
recursive: true,
});
console.log("[build-next-isolated] Copied docs/ to standalone output");
} catch (docsCopyErr) {
console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message);
}
try {
await pruneStandaloneArtifacts(projectRoot);
} catch (pruneErr) {
console.warn(
"[build-next-isolated] Non-fatal error pruning standalone artifacts:",
pruneErr
);
}
// Best-effort: build the TPROXY native addon (Linux-only, opt-in) BEFORE
// assembling, so its transparent.node is present for assembleStandalone's
// NATIVE_ASSET_ENTRIES copy. Non-Linux / no-toolchain is non-fatal — the
// capture mode degrades gracefully when the addon is absent.
try {
const { buildTproxyNative } = await import("./build-tproxy-native.mjs");
const res = buildTproxyNative(projectRoot);
console.log(
res.built
? "[build-next-isolated] Built TPROXY native addon (transparent.node)"
: `[build-next-isolated] TPROXY native addon skipped: ${res.reason}`
);
} catch (nativeErr) {
console.warn(
"[build-next-isolated] Non-fatal error building TPROXY native addon:",
nativeErr?.message
);
}
try {
console.log(
"[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
);
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot,
copyNatives: true,
});
} catch (assembleErr) {
console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
}
}
process.exitCode = result.code;
} catch (error) {
console.error("[build-next-isolated] Build failed:", error);
process.exitCode = 1;
} finally {
// Restore the stubbed dashboard pages FIRST so the working tree is clean even if the
// transient-path restore below throws.
restoreStubbedPagesOnce();
process.off("SIGINT", onFatalSignal);
process.off("SIGTERM", onFatalSignal);
while (movedPaths.length > 0) {
const entry = movedPaths.pop();
if (!entry) continue;
try {
await movePath(entry.backupPath, entry.sourcePath);
} catch (restoreError) {
console.error(
`[build-next-isolated] Failed to restore ${entry.label} from ${entry.backupPath}:`,
restoreError
);
process.exitCode = 1;
}
}
try {
await fs.rm(backupRoot, { recursive: true, force: true });
} catch (cleanupError) {
console.warn("[build-next-isolated] Failed to clean temporary backup root:", cleanupError);
}
}
}
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entryScript === import.meta.url) {
await main();
}