chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env node
/**
* assembleStandalone.mjs - Shared standalone bundle assembler for OmniRoute.
*
* Task 0.1 Inventory: Copy/sync operations across the three build scripts
* -----------------------------------------------------------------------
* Operation build-next-isolated prepublish electron Status
* --------------------------------------------------- ------------------- ----------- -------- ------
* .next/standalone -> outDir (cp) Y Y Y SHARED
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
* public/ -> outDir/public/ (cp) Y Y Y SHARED
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
* pino-pretty -> outDir/node_modules/pino-pretty Y - - SHARED (extra module)
* split2 -> outDir/node_modules/split2 Y - - SHARED (extra module)
* src/lib/db/migrations -> outDir/migrations Y Y - SHARED (extra module)
* src/mitm/server.cjs -> outDir/src/mitm/server.cjs Y - - SHARED (extra module)
* scripts/dev/run-standalone.mjs -> outDir/dev/run-standalone Y - - SHARED (extra module)
* scripts/dev/standalone-server-ws.mjs -> outDir/server-ws Y Y - SHARED (extra module)
* scripts/dev/peer-stamp.mjs -> outDir/peer-stamp.mjs Y Y - SHARED (extra module)
* scripts/dev/responses-ws-proxy.mjs -> outDir/responses-ws- Y Y - SHARED (extra module)
* scripts/build/runtime-env.mjs -> outDir/build/runtime-env Y - - SHARED (extra module)
* scripts/build/bootstrap-env.mjs -> outDir/build/bootstrap- Y - - SHARED (extra module)
* scripts/dev/healthcheck.mjs -> outDir/healthcheck.mjs Y - - SHARED (extra module)
* playwright-core -> outDir/node_modules/playwright-core Y - - SHARED (extra module)
* sqlite-vec -> outDir/node_modules/sqlite-vec Y - - SHARED (extra module)
* sqlite-vec-linux-x64/arm64/darwin-x64/arm64/win-x64 (same) Y - - SHARED (extra module)
* abs-path sanitization in server.js + required-server-files - Y Y SHARED (opt-in: sanitizePaths)
* Turbopack hashed-chunk patch (.next/server/ *.js) - Y - SHARED (opt-in: patchTurbopackChunks)
* --- npm-UNIQUE ---
* MITM tsc compile -> app/src/mitm/ - Y - UNIQUE (prepublish)
* MCP server esbuild -> dist/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish)
* CLI esbuild -> bin/omniroute.mjs - Y - UNIQUE (prepublish)
* sidecar/doc copies (.env.example, docs/, sync-env, etc.) - Y - UNIQUE (prepublish)
* prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish)
* data/ dir creation - Y - UNIQUE (prepublish)
* --- electron-UNIQUE ---
* better-sqlite3 + keytar native strip (ABI rebuild) - - Y UNIQUE (electron)
* symlink guard (assertBundleIsPackagable) - - Y UNIQUE (electron)
* removeGeneratedElectronArtifacts - - Y UNIQUE (electron)
*/
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
/**
* Check whether a path exists (async).
* @param {string} targetPath
* @returns {Promise<boolean>}
*/
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
/**
* SINGLE SOURCE OF TRUTH for the standalone bundle's native assets and extra
* modules/sidecars. Both the async path (syncStandaloneNativeAssets /
* syncStandaloneExtraModules, used by build-next-isolated + tests) and the sync
* path (copyNativeAssetsAndExtraModules, used by assembleStandalone) derive their
* copy lists from these arrays. Add a sidecar in ONE place — never two.
*
* Each entry uses path SEGMENT arrays (not pre-joined strings) so the source
* (relative to projectRoot) and destination (relative to outDir) can be joined
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
*/
const NATIVE_ASSET_ENTRIES = [
{
label: "wreq-js native runtime",
src: ["node_modules", "wreq-js", "rust"],
dest: ["node_modules", "wreq-js", "rust"],
},
{
label: "better-sqlite3 native binary",
src: ["node_modules", "better-sqlite3", "build"],
dest: ["node_modules", "better-sqlite3", "build"],
},
{
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
// builds → syncNativeAssetsToDir skips it gracefully. The runtime loader
// (transparentSocket.ts) resolves it cwd-relative to this same dest.
label: "TPROXY transparent-socket addon (Linux-only, opt-in)",
src: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
dest: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
},
];
/** @type {{label:string, src:string[], dest:string[]}[]} */
const EXTRA_MODULE_ENTRIES = [
{
label: "@swc/helpers",
src: ["node_modules", "@swc", "helpers"],
dest: ["node_modules", "@swc", "helpers"],
},
{
label: "pino-abstract-transport",
src: ["node_modules", "pino-abstract-transport"],
dest: ["node_modules", "pino-abstract-transport"],
},
{
label: "pino-pretty",
src: ["node_modules", "pino-pretty"],
dest: ["node_modules", "pino-pretty"],
},
{ label: "split2", src: ["node_modules", "split2"], dest: ["node_modules", "split2"] },
{ label: "migrations", src: ["src", "lib", "db", "migrations"], dest: ["migrations"] },
{ label: "MITM server", src: ["src", "mitm", "server.cjs"], dest: ["src", "mitm", "server.cjs"] },
{
label: "run-standalone script",
src: ["scripts", "dev", "run-standalone.mjs"],
dest: ["dev", "run-standalone.mjs"],
},
{
// WS-aware wrapper that run-standalone.mjs prefers over bare server.js.
// It installs the trusted peer-IP stamp the authz middleware needs to allow
// loopback/LAN access to LOCAL_ONLY routes; without it the Docker container
// fails closed (every LOCAL_ONLY request 403s). Imports peer-stamp.mjs +
// responses-ws-proxy.mjs, so all three are co-located.
label: "WS/peer-stamp standalone server wrapper",
src: ["scripts", "dev", "standalone-server-ws.mjs"],
dest: ["server-ws.mjs"],
},
{
label: "peer-stamp helper (server-ws.mjs dependency)",
src: ["scripts", "dev", "peer-stamp.mjs"],
dest: ["peer-stamp.mjs"],
},
{
label: "HTTP method guard (server-ws.mjs dependency)",
src: ["scripts", "dev", "http-method-guard.cjs"],
dest: ["http-method-guard.cjs"],
},
{
label: "responses-ws-proxy (server-ws.mjs dependency)",
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
dest: ["responses-ws-proxy.mjs"],
},
{
label: "webdav-handler (server-ws.mjs dependency)",
src: ["scripts", "dev", "webdav-handler.mjs"],
dest: ["webdav-handler.mjs"],
},
{
// #5242: opt-in HTTPS/TLS resolver (server-ws.mjs dependency).
label: "tls-options (server-ws.mjs dependency)",
src: ["scripts", "dev", "tls-options.mjs"],
dest: ["tls-options.mjs"],
},
{
label: "runtime-env script",
src: ["scripts", "build", "runtime-env.mjs"],
dest: ["build", "runtime-env.mjs"],
},
{
label: "bootstrap-env script",
src: ["scripts", "build", "bootstrap-env.mjs"],
dest: ["build", "bootstrap-env.mjs"],
},
{
label: "healthcheck script",
src: ["scripts", "dev", "healthcheck.mjs"],
dest: ["healthcheck.mjs"],
},
{ label: "public directory", src: ["public"], dest: ["public"] },
{
label: "playwright-core (dynamic import by gemini-web executor)",
src: ["node_modules", "playwright-core"],
dest: ["node_modules", "playwright-core"],
},
{
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
src: ["node_modules", "sqlite-vec"],
dest: ["node_modules", "sqlite-vec"],
},
// sqlite-vec's native vec0.so lives in a platform-specific package resolved at
// runtime via require.resolve(). Next.js does NOT trace it into the standalone
// (the externalized wrapper is copied, but its optional platform dep is missed -
// Next.js #88844), so without this the bundled/Docker build silently degrades
// vector search to FTS5: the wrapper loads but getLoadablePath() throws
// MODULE_NOT_FOUND. Copy whichever platform package npm actually installed. See #3066.
...[
"sqlite-vec-linux-x64",
"sqlite-vec-linux-arm64",
"sqlite-vec-darwin-x64",
"sqlite-vec-darwin-arm64",
"sqlite-vec-windows-x64",
].map((pkg) => ({ label: pkg, src: ["node_modules", pkg], dest: ["node_modules", pkg] })),
];
/**
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
*
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
* for backward compatibility with existing callers and tests.
*
* @param {string} rootDir - project root (node_modules are read from here)
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
* @param {Console|{log:Function}} [log] - logger
* @returns {Promise<boolean>} true if any asset was copied
*/
export async function syncStandaloneNativeAssets(rootDir, fsImpl = fs, log = console, outDir) {
const standaloneRoot =
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
return syncNativeAssetsToDir(rootDir, standaloneRoot, fsImpl, log);
}
/**
* Copy extra modules and sidecars into the Next.js standalone output.
*
* The destination is derived as <rootDir>/<distDir>/standalone/...
* where distDir defaults to ".build/next" (overridable via NEXT_DIST_DIR).
*
* @param {string} rootDir - project root
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
* @param {Console|{log:Function}} [log] - logger
* @returns {Promise<boolean>} true if any module was copied
*/
export async function syncStandaloneExtraModules(rootDir, fsImpl = fs, log = console, outDir) {
const standaloneRoot =
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
return syncExtraModulesToDir(rootDir, standaloneRoot, fsImpl, log);
}
/**
* Internal: copy native assets to an arbitrary outDir.
*
* @param {string} projectRoot
* @param {string} outDir
* @param {typeof fs} fsImpl
* @param {Console|{log:Function}} log
* @returns {Promise<boolean>}
*/
async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
let changed = false;
for (const entry of NATIVE_ASSET_ENTRIES) {
const sourcePath = path.join(projectRoot, ...entry.src);
if (!(await exists(sourcePath))) continue;
const destinationPath = path.join(outDir, ...entry.dest);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
force: true,
});
log.log(
`[assembleStandalone] Copied native standalone asset: ${path.relative(
projectRoot,
destinationPath
)}`
);
changed = true;
}
return changed;
}
/**
* Internal: copy extra modules/sidecars to an arbitrary outDir.
*
* @param {string} projectRoot
* @param {string} outDir
* @param {typeof fs} fsImpl
* @param {Console|{log:Function}} log
* @returns {Promise<boolean>}
*/
async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
let changed = false;
for (const entry of EXTRA_MODULE_ENTRIES) {
const sourcePath = path.join(projectRoot, ...entry.src);
if (!(await exists(sourcePath))) continue;
const destPath = path.join(outDir, ...entry.dest);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destPath), { recursive: true });
await fsImpl.cp(sourcePath, destPath, { recursive: true, force: true });
log.log(`[assembleStandalone] Synced standalone module: ${entry.label}`);
changed = true;
}
return changed;
}
/**
* Sanitize absolute build-machine paths in server.js and required-server-files.json.
* Replaces the build root with "." so paths resolve relative to wherever the standalone
* bundle is installed.
*
* @param {string} projectRoot - repo root (the path to replace)
* @param {string} outDir - assembled standalone output directory
* @returns {number} number of path replacements made
*/
export function assemblePathSanitize(projectRoot, outDir, distDir = ".next") {
const buildRoot = projectRoot.replaceAll("\\", "/"); // normalise for regex safety
const sanitizeTargets = [
path.join(outDir, "server.js"),
// required-server-files.json lives under the distDir (e.g. .build/next), not
// a literal .next — the standalone preserves the configured distDir path.
path.join(outDir, distDir, "required-server-files.json"),
];
let sanitisedCount = 0;
for (const filePath of sanitizeTargets) {
if (!fsSync.existsSync(filePath)) continue;
let content = fsSync.readFileSync(filePath, "utf8");
// Escape special regex characters in the path
const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const re = new RegExp(escaped, "g");
const matches = content.match(re);
if (matches) {
content = content.replace(re, ".");
fsSync.writeFileSync(filePath, content);
sanitisedCount += matches.length;
}
}
return sanitisedCount;
}
/**
* Strip Turbopack hashed externals from compiled chunks.
* Even when Turbopack is disabled at build time, some instrumentation chunks
* may still emit require('package-<16hexchars>') instead of require('package').
* We strip the hex suffix from all .js files in outDir/.next/server/.
*
* @param {string} outDir - assembled standalone output directory
* @returns {{ patchedFiles: number, patchedMatches: number }}
*/
export function patchTurbopackChunks(outDir, distDir = ".next") {
const serverOutput = path.join(outDir, distDir, "server");
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+?-[0-9a-f]{16}(?:\/[^'"\\]+)?)\1/g;
let patchedFiles = 0;
let patchedMatches = 0;
const walkDir = (dir) => {
let entries = [];
try {
entries = fsSync.readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry);
try {
const st = fsSync.statSync(full);
if (st.isDirectory()) {
walkDir(full);
continue;
}
if (!entry.endsWith(".js")) continue;
const src = fsSync.readFileSync(full, "utf8");
let count = 0;
const patched = src.replace(HASH_RE, (_, q, name) => {
const base = name.replace(/-[0-9a-f]{16}(?=\/|$)/, "");
count++;
return `${q}${base}${q}`;
});
if (count > 0) {
fsSync.writeFileSync(full, patched);
patchedFiles++;
patchedMatches += count;
}
} catch {
/* skip unreadable files */
}
}
};
if (fsSync.existsSync(serverOutput)) {
walkDir(serverOutput);
}
return { patchedFiles, patchedMatches };
}
/**
* Next.js standalone's server.js is CommonJS (uses require()), but the root package.json
* (which Next copies into the standalone) has "type":"module". Strip "type" so Node treats
* .js files as CJS in the bundle dir — otherwise `node server.js` fails with
* "require is not defined in ES module scope".
*
* @param {string} resolvedOutDir - assembled standalone output directory
*/
function patchStandalonePackageJson(resolvedOutDir) {
const outDirPkgJson = path.join(resolvedOutDir, "package.json");
if (!fsSync.existsSync(outDirPkgJson)) return;
try {
const pkg = JSON.parse(fsSync.readFileSync(outDirPkgJson, "utf8"));
if (pkg.type !== "module") return;
delete pkg.type;
fsSync.writeFileSync(outDirPkgJson, JSON.stringify(pkg, null, 2) + "\n");
console.log(
"[assembleStandalone] Removed 'type':'module' from standalone package.json (server.js is CJS)"
);
} catch (err) {
console.warn(`[assembleStandalone] Could not patch standalone package.json: ${err.message}`);
}
}
/**
* Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
* The static dest mirrors the configured distDir (e.g. .build/next), which is where the
* standalone server serves /_next/static from. See step 2 in assembleStandalone for why.
*
* @param {{ distDir: string, relDistDir: string, projectRoot: string, resolvedOutDir: string }} opts
*/
function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir }) {
const staticSrc = path.join(distDir, "static");
const staticDest = path.join(resolvedOutDir, relDistDir, "static");
if (fsSync.existsSync(staticSrc)) {
fsSync.mkdirSync(path.dirname(staticDest), { recursive: true });
fsSync.cpSync(staticSrc, staticDest, { recursive: true, force: true });
}
const publicSrc = path.join(projectRoot, "public");
if (fsSync.existsSync(publicSrc)) {
fsSync.cpSync(publicSrc, path.join(resolvedOutDir, "public"), { recursive: true, force: true });
}
}
/**
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
* into the assembled bundle. Missing sources are skipped silently.
*
* @param {string} projectRoot
* @param {string} resolvedOutDir
*/
function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
for (const asset of NATIVE_ASSET_ENTRIES) {
const src = path.join(projectRoot, ...asset.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...asset.dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
}
for (const mod of EXTRA_MODULE_ENTRIES) {
const src = path.join(projectRoot, ...mod.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...mod.dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
}
}
/**
* Assemble the Next.js standalone bundle into outDir.
*
* Copies <distDir>/standalone -> outDir, then <distDir>/static -> outDir/.next/static,
* projectRoot/public -> outDir/public, native assets, and extra modules/sidecars.
* Optionally sanitizes abs paths and patches Turbopack chunks.
*
* This is a synchronous function for use in build scripts.
*
* @param {object} opts
* @param {string} opts.distDir - Next.js distDir (e.g. ".next" or ".build/next")
* @param {string} opts.outDir - destination directory for the assembled bundle
* @param {string} [opts.projectRoot] - repo root; defaults to process.cwd()
* @param {boolean} [opts.sanitizePaths] - replace build-machine abs paths with "." (default false)
* @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false)
* @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true)
* @returns {void}
*/
export function assembleStandalone({
distDir,
outDir,
projectRoot = process.cwd(),
sanitizePaths = false,
patchTurbopackChunks: doPatchChunks = false,
copyNatives = true,
}) {
if (!distDir) throw new Error("[assembleStandalone] distDir is required");
if (!outDir) throw new Error("[assembleStandalone] outDir is required");
// The standalone bundle preserves the distDir path RELATIVE to projectRoot
// (the server's baked config uses e.g. "./.build/next"), so output dest paths
// for static / required-server-files / server chunks must use the relative
// distDir appended to outDir — never the absolute build-machine distDir.
const relDistDir = path.isAbsolute(distDir) ? path.relative(projectRoot, distDir) : distDir;
const standaloneDir = path.resolve(path.join(distDir, "standalone"));
const resolvedOutDir = path.resolve(outDir);
if (!fsSync.existsSync(standaloneDir)) {
throw new Error(
`[assembleStandalone] standalone dir not found: ${standaloneDir}. Run \`next build\` first.`
);
}
// 1. Copy <distDir>/standalone -> outDir (skip when outDir IS the standalone dir — in-place mode)
fsSync.mkdirSync(resolvedOutDir, { recursive: true });
if (resolvedOutDir !== standaloneDir) {
fsSync.cpSync(standaloneDir, resolvedOutDir, { recursive: true });
}
// 1.5. Standalone server.js is CJS — strip "type":"module" from the copied package.json.
patchStandalonePackageJson(resolvedOutDir);
// 2/3. Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
// CRITICAL: the standalone server.js is built with distDir baked into its config
// (e.g. "./.build/next"), so it serves /_next/static from <outDir>/<relDistDir>/static,
// NOT a literal <outDir>/.next/static. Copying to .next/static leaves the server's
// static dir empty → every JS/CSS chunk 404s → blank page. Mirror the distDir path.
copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir });
// 4. Optionally sanitize abs paths
if (sanitizePaths) {
const count = assemblePathSanitize(projectRoot, resolvedOutDir, relDistDir);
if (count > 0) {
console.log(`[assembleStandalone] Sanitised ${count} hardcoded path references`);
}
}
// 5. Optionally patch Turbopack hashed chunks
if (doPatchChunks) {
const { patchedFiles, patchedMatches } = patchTurbopackChunks(resolvedOutDir, relDistDir);
if (patchedMatches > 0) {
console.log(
`[assembleStandalone] Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`
);
}
}
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
}
}
+210
View File
@@ -0,0 +1,210 @@
#!/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)`);
}
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
/**
* OmniRoute — Zero-Config Bootstrap
*
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in DATA_DIR
*
* Priority (lowest → highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const require = createRequire(import.meta.url);
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ keys: ["ANTIGRAVITY_OAUTH_CLIENT_SECRET"], label: "Antigravity OAuth" },
{ keys: ["QODER_OAUTH_CLIENT_SECRET"], label: "Qoder OAuth" },
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath, env = process.env) {
if (overridePath?.trim()) return resolve(overridePath);
const configured = env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(join(resolveDataDir(null, env), ".env"));
candidates.push(join(process.cwd(), ".env"));
return candidates.find((filePath) => existsSync(filePath)) ?? null;
}
function isNativeSqliteLoadError(error) {
const message = error instanceof Error ? error.message : String(error);
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
return (
message.includes("Module did not self-register") ||
message.includes("NODE_MODULE_VERSION") ||
message.includes("ERR_DLOPEN_FAILED") ||
message.includes("Could not locate the bindings file") ||
message.includes("Cannot find module 'better-sqlite3'") ||
code === "ERR_DLOPEN_FAILED" ||
code === "MODULE_NOT_FOUND"
);
}
function hasEncryptedCredentials(dataDir) {
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
if (isNativeSqliteLoadError(error)) {
return false;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
const env = {};
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx < 1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim());
env[key] = val;
}
return env;
}
function unquoteEnvValue(value) {
if (value.length < 2) return value;
const quote = value[0];
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
return value.slice(1, -1);
}
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
function writeEnvFile(filePath, env) {
const lines = [
"# Auto-generated by OmniRoute bootstrap — do not delete",
`# Created: ${new Date().toISOString()}`,
"",
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
"",
];
writeFileSync(filePath, lines.join("\n"), "utf8");
}
// ── Main bootstrap function ──────────────────────────────────────────────────
/**
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
* @returns {Record<string, string>} merged env to pass to child process
*/
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
const serverEnvPath = join(dataDir, "server.env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
//
// We strip empty values from preferredEnv so an empty placeholder
// (e.g. `STORAGE_ENCRYPTION_KEY=` in the project .env template) does not
// override the real value persisted in server.env. Only the .env entries
// that the operator actually set should win.
const preferredEnvFiltered = Object.fromEntries(
Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0)
);
const merged = { ...persisted, ...preferredEnvFiltered, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
if (!merged.JWT_SECRET?.trim()) {
persisted.JWT_SECRET = randomBytes(64).toString("hex");
merged.JWT_SECRET = persisted.JWT_SECRET;
needsPersist = true;
log("✨ JWT_SECRET auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
if (hasEncryptedCredentials(dataDir)) {
throw new Error(
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
}
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
needsPersist = true;
}
if (!merged.API_KEY_SECRET?.trim()) {
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
needsPersist = true;
log("✨ API_KEY_SECRET auto-generated (first run)");
}
// ── Persist new secrets ────────────────────────────────────────────────────
if (needsPersist) {
try {
mkdirSync(dataDir, { recursive: true });
// Only persist keys that we auto-generated (not .env or process.env vals)
writeEnvFile(serverEnvPath, persisted);
log(`📁 Secrets persisted to: ${serverEnvPath}`);
} catch (e) {
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
}
}
// ── Mark as bootstrapped ───────────────────────────────────────────────────
if (needsPersist) {
merged.OMNIROUTE_BOOTSTRAPPED = "true";
}
// ── Warn about missing optional OAuth secrets ──────────────────────────────
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(
({ keys }) => !keys.some((key) => merged[key]?.trim())
);
if (missingOauth.length > 0) {
log("️ The following OAuth integrations are not configured:");
for (const { keys, label } of missingOauth) {
log(`${label} (${keys.join(" or ")}) — set in .env or ${serverEnvPath}`);
}
log(" These providers will not work until configured.");
}
// ── Warn about default password ────────────────────────────────────────────
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
}
// ── Decrypt-probe: verify STORAGE_ENCRYPTION_KEY matches encrypted data (#1622) ─
if (merged.STORAGE_ENCRYPTION_KEY?.trim() && hasEncryptedCredentials(dataDir)) {
try {
const Database = require("better-sqlite3");
const db = new Database(join(dataDir, "storage.sqlite"), {
readonly: true,
fileMustExist: true,
});
try {
const row = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
if (row) {
const ciphertext = row.api_key || row.access_token || row.refresh_token || row.id_token;
if (ciphertext?.startsWith("enc:v1:")) {
const parts = ciphertext.split(":");
// enc:v1:<iv>:<ct>:<tag>
if (parts.length >= 5) {
const iv = Buffer.from(parts[2], "hex");
const ct = Buffer.from(parts[3], "hex");
const tag = Buffer.from(parts[4], "hex");
// Try decrypting with both key derivation methods matching encryption.ts
const tryDecrypt = (derivedKey) => {
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
decipher.setAuthTag(tag);
decipher.update(ct);
decipher.final();
};
// Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32)
const dynamicSalt = createHash("sha256")
.update(merged.STORAGE_ENCRYPTION_KEY)
.digest()
.slice(0, 16);
const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32);
// Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32)
const legacySalt = "omniroute-field-encryption-v1";
const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32);
let keyMatched = false;
try {
tryDecrypt(dynamicKey);
keyMatched = true;
} catch {
// Try legacy key as fallback
try {
tryDecrypt(legacyKey);
keyMatched = true;
} catch {
// Both failed — key truly doesn't match
}
}
if (!keyMatched) {
log(
"⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials."
);
log(
" Either restore your previous key via ~/.omniroute/server.env or ~/.omniroute/.env,"
);
log(
" or run: omniroute reset-encrypted-columns --force (wipes credentials, keeps provider config)"
);
}
}
}
}
} finally {
db.close();
}
} catch {
// Non-fatal — probe is best-effort
}
}
return merged;
}
// ── CLI usage: node scripts/build/bootstrap-env.mjs ──────────────────────────────
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
const env = bootstrapEnv();
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
process.stderr.write(
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
);
}
+322
View File
@@ -0,0 +1,322 @@
#!/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();
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Best-effort build of the TPROXY IP_TRANSPARENT native addon so the production
* build can copy `build/Release/transparent.node` into the standalone bundle
* (assembleStandalone's NATIVE_ASSET_ENTRIES). Called from build-next-isolated.mjs
* before the standalone is assembled.
*
* IP_TRANSPARENT is Linux-only, so this is a no-op everywhere else. A missing C
* toolchain is NOT fatal — the TPROXY capture mode degrades gracefully when the
* addon is absent (transparentSocket.ts returns "unavailable"). Every effectful
* seam (platform/run/exists) is injectable so the decision logic is unit-testable.
*
* Hard Rule #13: the command + args are a fixed allowlist (no interpolation of
* external/runtime values); `cwd` is derived from `projectRoot`, never user input.
*/
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
/**
* @param {string} projectRoot
* @param {{ platform?: string, run?: (cmd:string, args:string[], cwd:string) => void,
* exists?: (p:string) => boolean }} [opts]
* @returns {{ built: boolean, reason?: string }}
*/
export function buildTproxyNative(projectRoot, opts = {}) {
const platform = opts.platform ?? process.platform;
const run = opts.run ?? defaultRun;
const exists = opts.exists ?? existsSync;
if (platform !== "linux") {
return { built: false, reason: "non-linux host (IP_TRANSPARENT is Linux-only)" };
}
const nativeDir = path.join(projectRoot, "src", "mitm", "tproxy", "native");
if (!exists(path.join(nativeDir, "binding.gyp"))) {
return { built: false, reason: "native sources absent (binding.gyp not found)" };
}
const out = path.join(nativeDir, "build", "Release", "transparent.node");
try {
run("npx", ["--yes", "node-gyp", "rebuild"], nativeDir);
} catch (err) {
return { built: false, reason: `toolchain/build failed: ${err?.message ?? String(err)}` };
}
if (!exists(out)) {
return { built: false, reason: "node-gyp produced no transparent.node" };
}
return { built: true };
}
/** @type {(cmd: string, args: string[], cwd: string) => void} */
function defaultRun(cmd, args, cwd) {
execFileSync(cmd, args, { cwd, stdio: "inherit" });
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
* `@atjsh/llmlingua-2` (not a regular dependency) and is already bundled in `dist/node_modules`,
* so the closure walk never reaches it and the no-clobber guard would skip it anyway.
*
* ## Validation (Hard Rule #18)
*
* Manual co-location of this exact closure on the production VPS produced real 54.8% compression
* (11520 → 5203 chars) via real ONNX inference — both the default and the `modelPath` (PR #4257)
* code paths. See the unit test for the closure-walk + no-clobber contract.
*
* Idempotent + fail-soft: skips when the optionals are absent (the common case — they are OPTIONAL)
* or already co-located; a per-package copy failure only disables the SLM tier, which is itself
* fail-open, so this never throws into the install.
*/
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
/**
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
* `optionalDependencies` from a `node_modules` directory. Packages that are not present in that
* tree (e.g. peers provided elsewhere, like `@huggingface/transformers` in `dist`) are skipped —
* the closure only contains packages that actually exist in `nodeModulesDir`.
*
* @param {string} nodeModulesDir absolute path to the source `node_modules`
* @param {string[]} [seeds] closure roots (defaults to {@link SEED_PACKAGES})
* @returns {string[]} package names in discovery order, seeds first
*/
export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) {
const closure = [];
const seen = new Set();
const stack = [...seeds];
while (stack.length) {
const name = stack.shift();
if (seen.has(name)) continue;
seen.add(name);
const pkgDir = join(nodeModulesDir, name);
if (!existsSync(pkgDir)) continue; // absent in this tree (peer provided elsewhere) — skip
closure.push(name);
let manifest;
try {
manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
} catch {
continue; // unreadable/absent manifest — copy the dir but do not recurse
}
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
for (const dep of Object.keys(deps)) {
if (!seen.has(dep)) stack.push(dep);
}
}
return closure;
}
/**
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
* absent, and idempotent once co-located. Never throws.
*
* @param {{ rootDir: string, log?: (message: string) => void }} opts
* @returns {{ skipped: true, reason: string }
* | { skipped: false, copied: number, closure: number }}
*/
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
const rootNm = join(rootDir, "node_modules");
const distNm = join(rootDir, "dist", "node_modules");
if (!existsSync(distNm)) {
return { skipped: true, reason: "no standalone dist/node_modules" };
}
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
return { skipped: true, reason: "SLM optionals not installed at root" };
}
// Idempotent: the entry package is already co-located → nothing to do.
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
return { skipped: true, reason: "already co-located" };
}
const closure = computeDependencyClosure(rootNm);
let copied = 0;
for (const name of closure) {
const dest = join(distNm, name);
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
try {
mkdirSync(dirname(dest), { recursive: true });
cpSync(join(rootNm, name), dest, { recursive: true });
copied++;
} catch (err) {
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
}
}
if (copied > 0) {
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
}
return { skipped: false, copied, closure: closure.length };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Decide whether the Turbopack build should alias @/mitm/manager to the
* feature-degraded stub (src/mitm/manager.stub.ts).
*
* History (#6344): the alias used to be UNCONDITIONAL in next.config.mjs
* because Docker images were the only Turbopack consumers (webpack was the
* production default and never aliased the manager). When v3.8.45 flipped the
* production bundler default to Turbopack, the stub silently shipped to every
* npm / Electron / VPS artifact — Agent Bridge start then threw
* "MITM manager stub reached at runtime" for all non-Docker users.
*
* The stub is only correct where the runtime genuinely cannot run the MITM
* stack (containers without host access — #3390 graceful degradation), so it
* is now opt-in via OMNIROUTE_MITM_STUB=1, set by the Dockerfile.
*/
export function shouldStubMitmManager(env = process.env) {
return env.OMNIROUTE_MITM_STUB === "1";
}
/** Turbopack resolveAlias fragment for @/mitm/manager, derived from the env. */
export function mitmManagerAliasFor(env = process.env) {
return shouldStubMitmManager(env) ? { "@/mitm/manager": "./src/mitm/manager.stub.ts" } : {};
}
+167
View File
@@ -0,0 +1,167 @@
import { existsSync, openSync, readSync, closeSync } from "node:fs";
export const PUBLISHED_BUILD_PLATFORM = "linux";
export const PUBLISHED_BUILD_ARCH = "x64";
const HEADER_SIZE = 4096;
const MAX_FAT_ARCH_COUNT = 30;
function mapElfMachine(machine) {
switch (machine) {
case 62:
return "x64";
case 183:
return "arm64";
default:
return null;
}
}
function mapMachCpuType(cpuType) {
switch (cpuType) {
case 0x01000007:
return "x64";
case 0x0100000c:
return "arm64";
default:
return null;
}
}
function mapPeMachine(machine) {
switch (machine) {
case 0x8664:
return "x64";
case 0xaa64:
return "arm64";
default:
return null;
}
}
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
const ELF_MAGIC = 0x7f454c46;
function detectElfTarget(buffer) {
if (buffer.length < 20) return null;
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
const littleEndian = buffer[5] !== 2;
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
if (!arch) return null;
return { platform: "linux", architectures: [arch] };
}
const THIN_MACH_MAGIC = new Map([
[0xfeedface, false],
[0xfeedfacf, false],
[0xcefaedfe, true],
[0xcffaedfe, true],
]);
const FAT_MACH_MAGIC = new Map([
[0xcafebabe, false],
[0xcafebabf, false],
[0xbebafeca, true],
[0xbfbafeca, true],
]);
function detectMachTarget(buffer) {
if (buffer.length < 8) return null;
const magic = buffer.readUInt32BE(0);
if (THIN_MACH_MAGIC.has(magic)) {
const littleEndian = THIN_MACH_MAGIC.get(magic);
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
if (!arch) return null;
return { platform: "darwin", architectures: [arch] };
}
if (!FAT_MACH_MAGIC.has(magic)) return null;
const littleEndian = FAT_MACH_MAGIC.get(magic);
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
const archCount = readUInt32(buffer, 4, littleEndian);
if (archCount > MAX_FAT_ARCH_COUNT) return null;
const entrySize = isFat64 ? 32 : 20;
const architectures = new Set();
for (let index = 0; index < archCount; index += 1) {
const offset = 8 + index * entrySize;
if (offset + 4 > buffer.length) break;
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
if (arch) architectures.add(arch);
}
if (architectures.size === 0) return null;
return { platform: "darwin", architectures: [...architectures] };
}
function detectPeTarget(buffer) {
if (buffer.length < 0x40) return null;
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
const peHeaderOffset = buffer.readUInt32LE(0x3c);
if (peHeaderOffset + 6 > buffer.length) return null;
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
if (!arch) return null;
return { platform: "win32", architectures: [arch] };
}
export function detectNativeBinaryTarget(buffer) {
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
}
export function readNativeBinaryTarget(binaryPath) {
if (!existsSync(binaryPath)) return null;
let fd;
try {
fd = openSync(binaryPath, "r");
const buffer = Buffer.alloc(HEADER_SIZE);
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
} catch (err) {
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
return null;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export function isNativeBinaryCompatible(
binaryPath,
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
) {
const target = readNativeBinaryTarget(binaryPath);
if (target) {
if (
(target.platform !== runtimePlatform &&
!(target.platform === "linux" && runtimePlatform === "android")) ||
!target.architectures.includes(runtimeArch)
) {
return false;
}
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
return false;
}
try {
dlopen({ exports: {} }, binaryPath);
return true;
} catch (err) {
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
return false;
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Shared policy for OmniRoute npm publish artifact hygiene.
*
* The package publishes the standalone runtime under dist/ (Layer 1: renamed from app/).
* This policy keeps local backups, QA scratch files, and development-only
* directories out of the staged dist/ tree and out of the final tarball.
*/
const STAGING_FORBIDDEN_DIRECTORIES = [
"app.__qa_backup",
"coverage",
"electron",
"logs",
"scripts/scratch",
"tests",
"vscode-extension",
"_ideia",
"_mono_repo",
"_references",
"_tasks",
];
const STAGING_FORBIDDEN_FILES = ["audit-report.json", "package-lock.json"];
export const APP_STAGING_REMOVAL_PATHS: string[] = [
...STAGING_FORBIDDEN_DIRECTORIES,
...STAGING_FORBIDDEN_FILES,
// onnxruntime CUDA provider binary (~316 MB) inflates the npm tarball
// past the registry 413 limit for npm.org. It's only needed on systems
// with a CUDA GPU — users install CUDA providers separately.
"node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so",
];
export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"BUILD_SHA",
"docs/openapi.yaml",
"http-method-guard.cjs",
"open-sse/mcp-server/server.js",
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
"package.json",
"peer-stamp.mjs",
"responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
"scripts/dev/tls-options.mjs",
"server.js",
"server-ws.mjs",
// #5452: dist/tls-options.mjs is copied by assembleStandalone (EXTRA_MODULE_ENTRIES)
// and imported by dist/server-ws.mjs for opt-in native HTTPS/TLS (#5361). Without
// this bare entry the prepublish prune (Step 10.7) deletes it → `omniroute serve`
// crashes with ERR_MODULE_NOT_FOUND (regressed in the published 3.8.41 tarball).
"tls-options.mjs",
"webdav-handler.mjs",
];
export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [
// Layer 1: Next.js distDir changed from ".next" to ".build/next"; the server
// bundle now lives under .build/next/ inside the standalone output.
".build/next/",
".next/",
"data/",
"node_modules/",
"open-sse/services/compression/engines/rtk/filters/",
"open-sse/services/compression/rules/",
"public/",
"src/lib/db/migrations/",
"src/mitm/",
];
export const PACK_ARTIFACT_ALLOWED_EXACT_PATHS: string[] = APP_STAGING_ALLOWED_EXACT_PATHS.map(
(filePath: string) => `dist/${filePath}`
);
export const PACK_ARTIFACT_ALLOWED_PATH_PREFIXES: string[] = APP_STAGING_ALLOWED_PATH_PREFIXES.map(
(directoryPath: string) => `dist/${directoryPath}`
);
export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"LICENSE",
"README.md",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
// Operator incident-recovery / cold-start shell tooling (rollback, snapshot,
// restore, cold-start bench) shipped in bin/ for self-hosters — not imported by
// the runtime. Included via the package.json "files": ["bin/"] entry, so they
// must be allowed here. Each script is self-documenting via --help.
"bin/_ops-common.sh",
"bin/cold-start-bench.sh",
"bin/restore-data.sh",
"bin/restore-policies.sh",
"bin/rollback.sh",
"bin/snapshot-data.sh",
"open-sse/mcp-server/README.md",
"open-sse/mcp-server/audit.ts",
"open-sse/mcp-server/httpTransport.ts",
"open-sse/mcp-server/index.ts",
"open-sse/mcp-server/runtimeHeartbeat.ts",
"open-sse/mcp-server/scopeEnforcement.ts",
"open-sse/mcp-server/server.ts",
// Runtime polyfill eagerly imported by bin/omniroute.mjs (Node <22 compat);
// shipped via package.json "files", so it must be allowed in the tarball.
"open-sse/utils/setupPolyfill.ts",
"package.json",
"scripts/build/build-next-isolated.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
// #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration).
"scripts/build/runtime-env.mjs",
"scripts/build/sync-env.mjs",
"scripts/dev/responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
// #5361: imported at runtime by bin/cli/commands/serve.mjs + the standalone
// server wrapper for opt-in native HTTPS/TLS serving (kept dependency-light).
"scripts/dev/tls-options.mjs",
"scripts/postinstall.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",
];
export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
"@omniroute/opencode-plugin/",
"@omniroute/opencode-provider/",
"bin/cli/",
// Broad open-sse + src source dirs added to package.json "files" in v3.8.21
// to allow TypeScript-first imports from the published package.
"open-sse/",
"src/domain/",
"src/lib/",
"src/models/",
"src/mitm/",
"src/server/",
"src/shared/",
"src/sse/",
"src/types/",
];
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"dist/open-sse/services/compression/engines/rtk/filters/generic-output.json",
"dist/open-sse/services/compression/rules/en/filler.json",
"dist/server.js",
"dist/server-ws.mjs",
"dist/responses-ws-proxy.mjs",
"dist/peer-stamp.mjs",
"dist/http-method-guard.cjs",
// #5452: regression guard — make check:pack-artifact fail loudly if the TLS
// opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball.
"dist/tls-options.mjs",
"dist/webdav-handler.mjs",
"bin/cli/program.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"package.json",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/runtime-env.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",
];
PACK_ARTIFACT_ALLOWED_EXACT_PATHS.push(...PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS);
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES.push(...PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES);
export function normalizeArtifactPath(filePath: string): string {
return String(filePath || "")
.replace(/\\/g, "/")
.replace(/^\.\//, "")
.replace(/^\/+/, "")
.replace(/\/{2,}/g, "/");
}
export function findUnexpectedArtifactPaths(
filePaths: string[],
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
): string[] {
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter(
(filePath) =>
!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix))
)
.sort();
}
export function findMissingArtifactPaths(
filePaths: string[],
requiredPaths: string[] = []
): string[] {
const normalizedPaths = new Set(filePaths.map(normalizeArtifactPath).filter(Boolean));
return requiredPaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((requiredPath) => !normalizedPaths.has(requiredPath))
.sort();
}
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
/**
* OmniRoute — Postinstall Native Module Fix
*
* The npm package ships with a Next.js standalone build that includes
* native modules compiled for the build platform (Linux x64) inside
* dist/node_modules/. However, npm also installs these as top-level
* dependencies (in the root node_modules/), correctly compiled for
* the user's platform.
*
* This script copies the correctly-built native binaries from the root
* into the standalone dist directory — no rebuild or build tools needed.
*
* Modules repaired:
* - better-sqlite3 (SQLite bindings)
* - wreq-js (TLS client for OAuth providers)
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const appBinary = join(
ROOT,
"dist",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
const rootBinary = join(
ROOT,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
async function fixBetterSqliteBinary() {
if (!existsSync(join(ROOT, "dist", "node_modules", "better-sqlite3"))) {
return;
}
const platformMatch =
process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH;
if (platformMatch) {
try {
process.dlopen({ exports: {} }, appBinary);
return;
} catch (err) {
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
}
}
console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`);
if (existsSync(rootBinary)) {
try {
mkdirSync(dirname(appBinary), { recursive: true });
copyFileSync(rootBinary, appBinary);
} catch (err) {
console.warn(` ⚠️ Failed to copy binary: ${err.message}`);
}
try {
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module fixed successfully!\n");
return;
} catch (err) {
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
}
}
console.log(" 📥 Attempting to download prebuilt binary via node-pre-gyp...");
try {
const { execSync } = await import("node:child_process");
const preGypBin = join(
ROOT,
"dist",
"node_modules",
".bin",
process.platform === "win32" ? "node-pre-gyp.cmd" : "node-pre-gyp"
);
const preGypFallback = join(
ROOT,
"dist",
"node_modules",
"@mapbox",
"node-pre-gyp",
"bin",
"node-pre-gyp"
);
const preGypCmd = existsSync(preGypBin) ? preGypBin : preGypFallback;
if (existsSync(preGypCmd)) {
execSync(`"${process.execPath}" "${preGypCmd}" install --fallback-to-build=false`, {
cwd: join(ROOT, "dist", "node_modules", "better-sqlite3"),
stdio: "inherit",
timeout: 60_000,
});
mkdirSync(dirname(appBinary), { recursive: true });
try {
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Prebuilt binary downloaded and loaded successfully!\n");
return;
} catch (loadErr) {
console.warn(` ⚠️ Downloaded binary failed to load: ${loadErr.message}`);
}
} else {
console.warn(" ⚠️ node-pre-gyp not found, skipping prebuilt download.");
}
} catch (err) {
console.warn(` ⚠️ node-pre-gyp download failed: ${err.message.split("\n")[0]}`);
}
console.log(" ⚠️ Attempting npm rebuild (requires build tools)...");
try {
const { execSync } = await import("node:child_process");
// On Android/Termux, rebuild from source with --build-from-source flag
const isAndroid = process.platform === "android" || isTermux();
const rebuildCmd = isAndroid
? "npm install better-sqlite3 --build-from-source --force"
: "npm rebuild better-sqlite3";
const env = { ...process.env };
if (isAndroid) {
env.GYP_DEFINES = "android_ndk_path=''";
}
execSync(rebuildCmd, {
cwd: join(ROOT, "dist"),
stdio: "inherit",
timeout: isAndroid ? 600_000 : 300_000, // ARM compilation is slower
env,
});
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module rebuilt successfully!\n");
return;
} catch (err) {
const isTimeout = err.killed || err.signal === "SIGTERM";
if (isTimeout) {
const secs = isAndroid ? 600 : 300;
console.warn(` ⚠️ npm rebuild timed out after ${secs}s.`);
} else {
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
}
}
console.warn("\n ⚠️ Could not fix better-sqlite3 native module automatically.");
console.warn(" The server may not start correctly.");
console.warn(" Manual fix options:");
if (process.platform === "win32") {
console.warn(" Option A (easiest — no build tools needed):");
console.warn(` cd "${join(ROOT, "dist", "node_modules", "better-sqlite3")}"`);
console.warn(" npx @mapbox/node-pre-gyp install --fallback-to-build=false");
console.warn(" Option B (requires Build Tools for Visual Studio):");
console.warn(` cd "${join(ROOT, "dist")}" && npm rebuild better-sqlite3`);
console.warn(" Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/");
console.warn(" Also ensure Python is installed: https://python.org");
} else if (process.platform === "darwin") {
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
console.warn(" If build tools are missing: xcode-select --install");
} else {
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
}
console.warn("");
}
/**
* Fix wreq-js native binary for the standalone dist directory.
*
* wreq-js ships platform-specific .node binaries under rust/.
* The standalone build may only contain Linux binaries from the CI.
* This copies the correct platform binary from the root install.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
async function fixWreqJsBinary() {
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
// The runtime already falls back gracefully when wreq-js is unavailable.
if (process.platform === "android" || isTermux()) {
console.log(
" [postinstall] wreq-js: skipped on Termux/Android " +
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
);
return;
}
const appWreqDir = join(ROOT, "dist", "node_modules", "wreq-js", "rust");
const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust");
if (!existsSync(join(ROOT, "dist", "node_modules", "wreq-js"))) {
return;
}
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
const appBinaryPath = join(appWreqDir, binaryName);
const rootBinaryPath = join(rootWreqDir, binaryName);
// Check if the platform binary already exists and loads
if (existsSync(appBinaryPath)) {
try {
process.dlopen({ exports: {} }, appBinaryPath);
return; // Already working
} catch (err) {
console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`);
}
}
console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`);
// Strategy 1: Copy from root node_modules
if (existsSync(rootBinaryPath)) {
try {
mkdirSync(appWreqDir, { recursive: true });
copyFileSync(rootBinaryPath, appBinaryPath);
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed successfully!\n");
return;
} catch (err) {
console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`);
}
}
// Strategy 2: Copy entire rust/ directory from root (gets all platform binaries)
if (existsSync(rootWreqDir)) {
try {
mkdirSync(appWreqDir, { recursive: true });
const files = readdirSync(rootWreqDir);
for (const file of files) {
if (file.endsWith(".node")) {
copyFileSync(join(rootWreqDir, file), join(appWreqDir, file));
}
}
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`);
}
}
// Strategy 3: Rebuild wreq-js inside dist/
console.log(" 📥 Attempting npm rebuild wreq-js...");
try {
const { execSync } = await import("node:child_process");
execSync("npm rebuild wreq-js", {
cwd: join(ROOT, "dist"),
stdio: "inherit",
timeout: 120_000,
});
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module rebuilt successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`);
}
console.warn(
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
);
console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work.");
console.warn(` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js --no-save\n`);
}
async function ensureSwcHelpers() {
if (!hasStandaloneAppBundle(ROOT)) {
return;
}
const swcHelpersApp = join(ROOT, "dist", "node_modules", "@swc", "helpers");
const swcHelpersRoot = join(ROOT, "node_modules", "@swc", "helpers");
if (existsSync(swcHelpersApp)) {
return;
}
if (existsSync(swcHelpersRoot)) {
try {
const { cpSync } = await import("node:fs");
mkdirSync(join(ROOT, "dist", "node_modules", "@swc"), { recursive: true });
cpSync(swcHelpersRoot, swcHelpersApp, { recursive: true });
console.log(" ✅ @swc/helpers copied to standalone dist/node_modules.\n");
} catch (err) {
console.warn(` ⚠️ Could not copy @swc/helpers: ${err.message}`);
console.warn(
" Try manually: cp -r node_modules/@swc/helpers dist/node_modules/@swc/helpers\n"
);
}
return;
}
console.warn(" ⚠️ @swc/helpers not found in root node_modules either.");
console.warn(" Try: npm install --save-exact @swc/helpers@0.5.19\n");
}
async function syncProjectEnv() {
try {
const { syncEnv } = await import("./sync-env.mjs");
syncEnv({ rootDir: ROOT });
} catch (err) {
console.warn(` ⚠️ .env sync skipped: ${err.message}`);
}
}
/**
* Co-locate the LLMLingua-2 SLM optional dependency closure into dist/node_modules so the
* compression "ultra" SLM tier (PR #4257) resolves a single @huggingface/transformers instance at
* runtime. No-op unless the optionals were installed (`--include=optional`). See colocateOptionals.mjs.
*/
async function ensureLlmlinguaOptionals() {
try {
colocateLlmlinguaOptionals({ rootDir: ROOT, log: (m) => console.log(m) });
} catch (err) {
// Best-effort: the SLM tier is itself fail-open, so a co-location hiccup never fails the install.
console.warn(` ⚠️ LLMLingua optional co-location skipped: ${err.message}`);
}
}
await fixBetterSqliteBinary();
await fixWreqJsBinary();
await ensureSwcHelpers();
await ensureLlmlinguaOptionals();
await syncProjectEnv();
// Warm up native runtimes (better-sqlite3 in ~/.omniroute/runtime/).
// Non-fatal: errors are caught inside postinstall.mjs.
try {
await import("../postinstall.mjs");
} catch {
// Silently skip — runtime warm-up is best-effort.
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* Detect whether the current install tree contains the published standalone bundle.
* Checks for dist/server.js (Layer 1: renamed from app/server.js).
* Source checkouts will not have dist/ so postinstall skips platform-specific
* native repairs (which only apply to the shipped pre-built bundle).
*
* @param {string} rootDir
* @returns {boolean}
*/
export function hasStandaloneAppBundle(rootDir) {
// The published bundle ships in dist/ (build-output-isolation). Also accept the
// legacy app/ location so an upgrade over a partially-replaced install is still
// detected as a published bundle — mirrors the serve CLI's dist/ -> app/ fallback.
return (
existsSync(join(rootDir, "dist", "server.js")) ||
existsSync(join(rootDir, "app", "server.js"))
);
}
/**
* Returns true when running inside a Termux environment on Android.
*
* Node.js on Termux reports process.platform === "linux" (not "android"),
* so OS-level platform checks are insufficient. Use Termux-specific signals:
* 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable)
* 2. PREFIX env var containing "com.termux"
* 3. Filesystem probe at /data/data/com.termux (last resort, no env needed)
*
* @param {object} [env] Override process.env for testing.
* @returns {boolean}
*/
export function isTermux(env = process.env) {
if (env.TERMUX_VERSION) return true;
if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true;
try {
return existsSync("/data/data/com.termux");
} catch {
return false;
}
}
@@ -0,0 +1,195 @@
#!/usr/bin/env node
import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { assembleStandalone } from "./assembleStandalone.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const NEXT_DIST_DIR = process.env.NEXT_DIST_DIR || ".build/next";
const DIST_DIR = join(ROOT, NEXT_DIST_DIR);
const STANDALONE_DIR = join(DIST_DIR, "standalone");
const ELECTRON_STANDALONE_DIR = join(ROOT, ".build", "electron-standalone");
// --- Electron-UNIQUE: resolve the nested server.js location ----------------
function resolveStandaloneBundleDir() {
const directServer = join(STANDALONE_DIR, "server.js");
if (existsSync(directServer)) {
return STANDALONE_DIR;
}
const nestedCandidates = [
join(STANDALONE_DIR, "projects", "OmniRoute"),
join(STANDALONE_DIR, basename(ROOT)),
];
for (const candidate of nestedCandidates) {
if (existsSync(join(candidate, "server.js"))) {
return candidate;
}
}
throw new Error(
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
);
}
// --- Electron-UNIQUE: symlink guard (electron-builder fails on symlinked node_modules) ---
function assertBundleIsPackagable(bundleDir) {
const nodeModulesPath = join(bundleDir, "node_modules");
if (!existsSync(nodeModulesPath)) return;
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
throw new Error(
[
"Next standalone emitted app/node_modules as a symlink.",
"electron-builder preserves extraResources symlinks, which would make the packaged app",
"depend on the original build machine path at runtime.",
"",
`Offending path: ${nodeModulesPath}`,
"Use a real node_modules directory in the build worktree before packaging Electron.",
].join("\n")
);
}
}
// --- Electron-UNIQUE: strip generated electron artifacts from staged dir ---
function removeGeneratedElectronArtifacts() {
const generatedDirs = [join(ELECTRON_STANDALONE_DIR, "electron", "dist-electron")];
for (const dir of generatedDirs) {
rmSync(dir, { recursive: true, force: true });
}
}
// --- Electron-UNIQUE: remove native modules for electron-builder ABI rebuild ---
function removeNativeModules(baseDir, prefixes = ["keytar"]) {
if (!existsSync(baseDir)) return;
const dirs = readdirSync(baseDir);
for (const dir of dirs) {
if (prefixes.some((p) => dir.startsWith(p))) {
const fullPath = join(baseDir, dir);
rmSync(fullPath, { recursive: true, force: true });
}
}
}
// --- Electron-UNIQUE: rebuild better-sqlite3 against the Electron ABI --------
//
// The `npm ci` at the repo root compiles better-sqlite3 for the CI *Node* ABI
// (e.g. 137 for Node 24). The packaged app runs its Next.js server via
// ELECTRON_RUN_AS_NODE, so it needs the *Electron* ABI (146 for electron 42,
// 148 for electron 43). We cannot rely on electron-builder's @electron/rebuild
// here: it searches `electron/node_modules` (where better-sqlite3 does not live)
// and, with the default prebuild path, tries to fetch a prebuilt binary — but
// better-sqlite3@12.11.1 only ships prebuilds up to electron-v146, so electron
// 43 (v148) silently gets no rebuild and the app dies with "Nenhum driver
// SQLite disponível — better-sqlite3 (falhou)".
//
// Instead we copy the *full* module (source + binding.gyp) from the root into
// the standalone and compile it from source against the Electron headers, so
// `bindings` finds a correct build/Release/better_sqlite3.node regardless of
// prebuild availability. Robust to any current/future electron version.
function readElectronVersion() {
const pkg = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8"));
const raw = pkg.devDependencies?.electron || pkg.dependencies?.electron || "";
return String(raw).replace(/^[\^~]/, "");
}
function rebuildBetterSqlite3ForElectron(standaloneNodeModules) {
const srcMod = join(ROOT, "node_modules", "better-sqlite3");
if (!existsSync(srcMod)) {
console.warn("[electron] better-sqlite3 not found at repo root — skipping ABI rebuild.");
return;
}
const electronVersion = readElectronVersion();
if (!electronVersion) {
throw new Error("[electron] could not resolve electron version for better-sqlite3 rebuild.");
}
const destMod = join(standaloneNodeModules, "better-sqlite3");
// copyNatives only copies build/; we need the full module (src + binding.gyp)
// to compile from source. Overwrite the copied Node-ABI build in the process.
cpSync(srcMod, destMod, { recursive: true, force: true });
rmSync(join(destMod, "build"), { recursive: true, force: true });
console.log(`[electron] rebuilding better-sqlite3 against electron ${electronVersion} ABI…`);
const result = spawnSync(
process.platform === "win32" ? "npx.cmd" : "npx",
["--yes", "node-gyp", "rebuild"],
{
cwd: destMod,
stdio: "inherit",
// Compile against the Electron headers (not Node's) so the .node lands in
// build/Release with the Electron NODE_MODULE_VERSION. No shell interpolation.
env: {
...process.env,
npm_config_runtime: "electron",
npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers",
npm_config_arch: process.arch,
npm_config_build_from_source: "true",
},
}
);
if (result.status !== 0) {
throw new Error(
`[electron] better-sqlite3 rebuild against electron ${electronVersion} failed (exit ${result.status}).`
);
}
// Drop the now-unneeded compile inputs to keep the packaged app lean.
for (const dir of ["deps", "src", "build/Debug", "build/obj.target"]) {
rmSync(join(destMod, dir), { recursive: true, force: true });
}
}
function logContextualError(error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[electron] failed to prepare standalone bundle: ${message}`);
process.exitCode = 1;
}
process.on("uncaughtException", logContextualError);
// Resolve the bundle dir (handles nested project layout) and check for symlinks
const bundleDir = resolveStandaloneBundleDir();
assertBundleIsPackagable(bundleDir);
// Clean the stage dir before assembly
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
// Shared assembly: standalone copy + .next/static + public + abs-path sanitization + natives/@swc/helpers
assembleStandalone({
distDir: DIST_DIR,
outDir: ELECTRON_STANDALONE_DIR,
projectRoot: ROOT,
sanitizePaths: true,
copyNatives: true,
});
// Electron-UNIQUE post-assembly steps
removeGeneratedElectronArtifacts();
// Rebuild better-sqlite3 from source against the Electron ABI in the primary
// node_modules (where the standalone server resolves it). keytar is still
// stripped so electron-builder's @electron/rebuild handles it (it has electron
// prebuilds); also drop any stray Node-ABI better-sqlite3 under .next/node_modules
// so it cannot shadow the rebuilt one.
rebuildBetterSqlite3ForElectron(join(ELECTRON_STANDALONE_DIR, "node_modules"));
removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules"), ["keytar"]);
removeNativeModules(join(ELECTRON_STANDALONE_DIR, ".next", "node_modules"), [
"better-sqlite3",
"keytar",
]);
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
/**
* OmniRoute — Prepublish Build Script
*
* Consumes the .build/next/standalone artifact produced by `npm run build`
* (build-next-isolated.mjs) and assembles the npm staging `dist/` directory.
* Does NOT run a second `next build` — the caller must run `npm run build` first,
* or this script will invoke it exactly once if the artifact is absent.
*
* Run with: node scripts/build/prepublish.ts
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
cpSync,
rmSync,
writeFileSync,
readFileSync,
readdirSync,
statSync,
chmodSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
APP_STAGING_REMOVAL_PATHS,
findUnexpectedArtifactPaths,
} from "./pack-artifact-policy.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
const DIST_DIR = join(ROOT, "dist");
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
let entries: string[] = [];
try {
entries = readdirSync(dir);
} catch {
return files;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
walkFiles(fullPath, rootDir, files);
continue;
}
files.push(
fullPath
.replace(rootDir, "")
.replace(/^[/\\]/, "")
.replace(/\\/g, "/")
);
}
return files;
}
function removeEmptyDirectories(dir: string): boolean {
let entries: string[] = [];
try {
entries = readdirSync(dir);
} catch {
return false;
}
let hasFiles = false;
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
const childHasFiles = removeEmptyDirectories(fullPath);
if (!childHasFiles) {
rmSync(fullPath, { recursive: true, force: true });
} else {
hasFiles = true;
}
continue;
}
hasFiles = true;
}
return hasFiles;
}
console.log("🔨 OmniRoute — Building for npm publish...\n");
// ── Step 1: Clean previous dist/ directory ─────────────────
if (existsSync(DIST_DIR)) {
console.log(" 🧹 Cleaning previous dist/ directory...");
rmSync(DIST_DIR, { recursive: true, force: true });
}
// ── Step 2: Assert / trigger the Next.js standalone build ──
// prepublish no longer runs its own `next build`. It consumes the
// .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs).
// If the artifact is absent we invoke it exactly once.
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js");
if (!existsSync(standaloneServerJs)) {
console.log(" 🏗️ .build/next/standalone not found — running `npm run build` once...");
execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], {
cwd: ROOT,
stdio: "inherit",
});
if (!existsSync(standaloneServerJs)) {
console.error(
"\n ❌ Standalone build not found after `npm run build` at:",
standaloneServerJs
);
console.error(" Make sure next.config.mjs has: output: 'standalone'");
process.exit(1);
}
}
console.log(" ✅ Standalone artifact present:", standaloneServerJs);
// ── Step 37: Assemble standalone into dist/ ───────────────
// All shared copy/sync/sanitize/chunk-patch operations are delegated to
// assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow.
console.log(" 📋 Assembling standalone bundle into dist/...");
assembleStandalone({
distDir: join(ROOT, NEXT_DIST),
outDir: DIST_DIR,
projectRoot: ROOT,
sanitizePaths: true,
patchTurbopackChunks: true,
copyNatives: true,
});
console.log(" ✅ Standalone bundle assembled to dist/");
const distServer = join(DIST_DIR, "server.js");
const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs");
const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs");
if (existsSync(methodGuardSrc)) {
cpSync(methodGuardSrc, methodGuardDest);
}
if (existsSync(distServer)) {
const serverSource = readFileSync(distServer, "utf8");
if (!serverSource.includes("installHttpMethodGuard")) {
writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource);
console.log(" ✅ Patched dist/server.js with HTTP method guard.");
}
}
// ── Step 8: Compile + copy MITM cert utilities ─────────────
const mitmSrc = join(ROOT, "src", "mitm");
const mitmDest = join(DIST_DIR, "src", "mitm");
if (existsSync(mitmSrc)) {
console.log(" 🔨 Compiling MITM utilities (TypeScript → JavaScript)...");
mkdirSync(mitmDest, { recursive: true });
// Write a temporary tsconfig.json targeting the mitm directory
const mitmTsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
outDir: mitmDest,
rootDir: mitmSrc,
strict: false,
noImplicitAny: false,
strictNullChecks: false,
noEmitOnError: true,
allowImportingTsExtensions: true,
rewriteRelativeImportExtensions: true,
ignoreDeprecations: "6.0",
resolveJsonModule: true,
esModuleInterop: true,
skipLibCheck: true,
types: ["node"],
baseUrl: ".",
paths: {
"@/*": ["src/*"],
},
},
include: [mitmSrc + "/**/*"],
};
const tmpTsconfigPath = join(ROOT, "tsconfig.mitm.tmp.json");
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
try {
execFileSync(NPX_BIN, ["tsc", "-p", "tsconfig.mitm.tmp.json"], {
cwd: ROOT,
stdio: "inherit",
});
const mitmServerSrc = join(mitmSrc, "server.cjs");
if (existsSync(mitmServerSrc)) {
cpSync(mitmServerSrc, join(mitmDest, "server.cjs"));
}
console.log(" ✅ MITM utilities compiled to dist/src/mitm/");
} catch (err: any) {
console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message);
// Fallback: copy source files so at least they are present
cpSync(mitmSrc, mitmDest, { recursive: true });
} finally {
// Cleanup temp tsconfig
try {
rmSync(tmpTsconfigPath);
} catch {}
}
}
// ── Step 8.5: Bundle MCP server ────────────────────────────
const mcpSrcFile = join(ROOT, "open-sse", "mcp-server", "server.ts");
const mcpDestDir = join(DIST_DIR, "open-sse", "mcp-server");
const mcpDestFile = join(mcpDestDir, "server.js");
if (existsSync(mcpSrcFile)) {
console.log(" 🔨 Bundling MCP Server (TypeScript → JavaScript)...");
mkdirSync(mcpDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"open-sse/mcp-server/server.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/mcp-server/server.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
console.log(" ✅ MCP Server bundled to dist/open-sse/mcp-server/server.js");
} catch (err: any) {
console.warn(" ⚠️ MCP Server bundle error:", err.message);
}
}
// ── Step 8.6: Bundle LLMLingua ONNX worker ────────────────────────────
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// dynamically imported at runtime, and the worker fail-opens if any is absent.
const llmWorkerSrc = join(
ROOT,
"open-sse",
"services",
"compression",
"engines",
"llmlingua",
"onnxWorker.ts"
);
const llmWorkerDestDir = join(
DIST_DIR,
"open-sse",
"services",
"compression",
"engines",
"llmlingua"
);
if (existsSync(llmWorkerSrc)) {
console.log(" 🔨 Bundling LLMLingua ONNX worker (TypeScript → JavaScript)...");
mkdirSync(llmWorkerDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"open-sse/services/compression/engines/llmlingua/onnxWorker.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
console.log(
" ✅ LLMLingua worker bundled to dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js"
);
} catch (err: any) {
console.warn(" ⚠️ LLMLingua worker bundle error:", err.message);
}
}
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
if (existsSync(cliSrcFile)) {
console.log(" 🔨 Bundling CLI Entrypoint (TypeScript → JavaScript)...");
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"bin/omniroute.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=bin/omniroute.mjs",
],
{ cwd: ROOT, stdio: "inherit" }
);
chmodSync(cliDestFile, 0o755);
console.log(" ✅ CLI Entrypoint bundled to bin/omniroute.mjs");
} catch (err: any) {
console.warn(" ⚠️ CLI bundle error:", err.message);
}
}
// ── Step 8.8: Build @omniroute/opencode-plugin ──────────────
// The plugin ships bundled inside the omniroute npm package (see root
// package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be
// present in the publish tarball so `omniroute setup opencode` can copy it
// into the user's OpenCode plugin dir. If the build fails we surface the
// error — shipping without the plugin's dist breaks the documented install
// flow for every downstream user.
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
if (!pluginAlreadyBuilt) {
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
try {
// The plugin is a standalone package (not an npm workspace), so the root
// install never populates its node_modules — and tsup with `dts: true`
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
}
execFileSync(NPX_BIN, ["tsup"], {
cwd: opencodePluginSrc,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
});
console.log(" ✅ @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/");
} catch (err: any) {
console.error(" ❌ Failed to build @omniroute/opencode-plugin:", err.message);
console.error(" The published package would be missing the plugin dist.");
console.error(
" Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug."
);
process.exit(1);
}
} else {
console.log(" ✅ @omniroute/opencode-plugin dist/ already present (skipping rebuild)");
}
// Remove plugin node_modules after build — hard links created by npm install on Linux
// (CI runner) end up in the tarball as LINK entries, which npm registry rejects with
// E415 "Hard link is not allowed". The node_modules are only needed for the tsup build;
// they must not ship in the published package.
const pluginNodeModules = join(opencodePluginSrc, "node_modules");
if (existsSync(pluginNodeModules)) {
rmSync(pluginNodeModules, { recursive: true, force: true });
console.log(" 🧹 Removed @omniroute/opencode-plugin/node_modules (hard link guard)");
}
} else {
console.log(" ⏭️ @omniroute/opencode-plugin not found in workspace (skipping build)");
}
// ── Step 9: Copy shared utilities needed at runtime ────────
const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js");
const sharedApiKeyDest = join(DIST_DIR, "src", "shared", "utils");
if (existsSync(sharedApiKey)) {
console.log(" 📋 Copying shared utilities...");
mkdirSync(sharedApiKeyDest, { recursive: true });
cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js"));
}
// ── Step 9.5: Copy minimal runtime sidecars required outside .next ─────────
const envExampleSrc = join(ROOT, ".env.example");
if (existsSync(envExampleSrc)) {
cpSync(envExampleSrc, join(DIST_DIR, ".env.example"));
}
const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml");
if (existsSync(openapiSpecSrc)) {
const docsDest = join(DIST_DIR, "docs");
mkdirSync(docsDest, { recursive: true });
cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml"));
}
const docsMarkdownSrc = join(ROOT, "docs");
if (existsSync(docsMarkdownSrc)) {
const docsDest = join(DIST_DIR, "docs");
mkdirSync(docsDest, { recursive: true });
const mdFiles = readdirSync(docsMarkdownSrc).filter(
(f) => f.endsWith(".md") || f.endsWith(".mdx")
);
for (const mdFile of mdFiles) {
cpSync(join(docsMarkdownSrc, mdFile), join(docsDest, mdFile));
}
if (mdFiles.length > 0) {
console.log(`[prepublish] Copied ${mdFiles.length} docs markdown files to dist/docs/`);
}
}
const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs");
if (existsSync(syncEnvSrc)) {
const scriptsDest = join(DIST_DIR, "scripts");
mkdirSync(scriptsDest, { recursive: true });
cpSync(syncEnvSrc, join(scriptsDest, "sync-env.mjs"));
}
const migrationsSrc = join(ROOT, "src", "lib", "db", "migrations");
if (existsSync(migrationsSrc)) {
const migrationsDest = join(DIST_DIR, "src", "lib", "db", "migrations");
mkdirSync(join(DIST_DIR, "src", "lib", "db"), { recursive: true });
cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true });
}
const runtimeAssetDirs = [
{
source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"),
destination: join(DIST_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"),
},
{
source: join(ROOT, "open-sse", "services", "compression", "rules"),
destination: join(DIST_DIR, "open-sse", "services", "compression", "rules"),
},
];
for (const assetDir of runtimeAssetDirs) {
if (existsSync(assetDir.source)) {
mkdirSync(dirname(assetDir.destination), { recursive: true });
cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true });
}
}
// ── Step 10: Ensure data/ directory exists ──────────────────
mkdirSync(join(DIST_DIR, "data"), { recursive: true });
// ── Step 10.5: Copy @swc/helpers into standalone ───────────
// Next.js standalone tracer sometimes omits @swc/helpers from dist/node_modules/,
// causing MODULE_NOT_FOUND at runtime. Always copy it explicitly.
const swcHelpersSrc = join(ROOT, "node_modules", "@swc", "helpers");
const swcHelpersDst = join(DIST_DIR, "node_modules", "@swc", "helpers");
if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) {
console.log(" 📋 Copying @swc/helpers to standalone dist/node_modules...");
mkdirSync(join(DIST_DIR, "node_modules", "@swc"), { recursive: true });
cpSync(swcHelpersSrc, swcHelpersDst, { recursive: true });
console.log(" ✅ @swc/helpers included in standalone build.");
}
// ── Step 10.6: Remove development-only residue from staged dist/ ────────────
for (const relativePath of APP_STAGING_REMOVAL_PATHS) {
const targetPath = join(DIST_DIR, relativePath);
if (existsSync(targetPath)) {
console.log(` 🧹 Removing dist/${relativePath} (not needed in npm package)...`);
rmSync(targetPath, { recursive: true, force: true });
console.log(` ✅ dist/${relativePath} removed.`);
}
}
// ── Step 10.7: Prune any staged dist/ file outside the allowed runtime set ──
const stagedFiles = walkFiles(DIST_DIR);
const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
});
if (unexpectedStagedFiles.length > 0) {
console.log(" 🧹 Pruning unexpected files from staged dist/...");
unexpectedStagedFiles.forEach((unexpectedPath: string) => {
rmSync(join(DIST_DIR, unexpectedPath), { force: true });
console.log(` ✅ Removed dist/${unexpectedPath}`);
});
removeEmptyDirectories(DIST_DIR);
}
const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
});
if (remainingUnexpectedFiles.length > 0) {
console.error("\n ❌ Staged dist/ still contains unexpected publish artifacts:");
remainingUnexpectedFiles.forEach((violation: string) =>
console.error(` - dist/${violation}`)
);
process.exit(1);
}
// ── Done ───────────────────────────────────────────────────
const distPkg = join(DIST_DIR, "package.json");
if (existsSync(distPkg)) {
JSON.parse(readFileSync(distPkg, "utf8"));
console.log(`\n ✅ Build complete!`);
console.log(` Dist directory: dist/`);
console.log(` Server entry: dist/server.js`);
} else {
console.log(`\n ✅ Build complete! (dist/ ready for publish)`);
}
console.log("");
+140
View File
@@ -0,0 +1,140 @@
import { spawn } from "node:child_process";
export function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
/**
* Resolve the V8 heap ceiling (MB) for the server process from
* `OMNIROUTE_MEMORY_MB`, mirroring `omniroute serve`. Clamped to [64, 16384];
* invalid/unset → fallback (512). The standalone launcher uses this so
* OMNIROUTE_MEMORY_MB can override the Docker image's NODE_OPTIONS fallback
* without clobbering any other runtime flags (#2939).
* @param {string | number | undefined | null} value
* @param {number} [fallback]
*/
export function resolveMaxOldSpaceMb(value, fallback = 512) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback;
}
/**
* Derive a sane DEFAULT V8 heap ceiling (MB) from the host's physical RAM, used
* when `OMNIROUTE_MEMORY_MB` is unset. A fixed 512MB default crashed boxes with
* plenty of RAM under load (65 providers / 2600 models → "Ineffective
* mark-compacts near heap limit ~500MB"); see #5172 / #5160 / #5152. Targets
* ~35% of total RAM, clamped to [512, 4096]. Invalid/zero totalmem → 512.
* Pass the result as the `fallback` of {@link resolveMaxOldSpaceMb} so an
* explicit OMNIROUTE_MEMORY_MB override always wins.
* @param {number | undefined | null} totalmemBytes — typically `os.totalmem()`
*/
export function calibrateHeapFallbackMb(totalmemBytes) {
const totalMb = Number(totalmemBytes) / (1024 * 1024);
if (!Number.isFinite(totalMb) || totalMb <= 0) return 512;
const target = Math.floor(totalMb * 0.35);
return Math.min(4096, Math.max(512, target));
}
const MAX_OLD_SPACE_FLAG = "--max-old-space-size";
/**
* True when the caller already pinned the V8 heap via NODE_OPTIONS
* (`--max-old-space-size=…`). Used to decide whether `omniroute serve` may
* append/inject the calibrated default — a user-set value must always win.
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
*/
export function envHasExplicitHeapFlag(env) {
const sourceEnv = arguments.length === 0 ? process.env : env;
return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG);
}
/**
* Assemble the NODE_OPTIONS string for the spawned server, preserving any flags
* the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY
* overwrite NODE_OPTIONS with the calibrated `--max-old-space-size`, silently
* discarding a user-set `NODE_OPTIONS=--max-old-space-size=8192` (reporter set
* 8192 and still OOM'd at ~505MB). Mirrors the Electron (electron/main.js) and
* standalone (scripts/dev/run-standalone.mjs) launchers:
* - if NODE_OPTIONS already contains `--max-old-space-size`, keep it as-is
* (the user's value wins);
* - otherwise append the calibrated `--max-old-space-size=<memoryLimit>` to
* the existing NODE_OPTIONS, preserving unrelated flags (e.g.
* `--enable-source-maps`).
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
* @returns {string} the NODE_OPTIONS value to pass to the child process
*/
export function buildServerNodeOptions(env = process.env, memoryLimit) {
const existing = String(env?.NODE_OPTIONS || "").trim();
if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing;
return `${existing} ${MAX_OLD_SPACE_FLAG}=${memoryLimit}`.trim();
}
/**
* Build the leading `node` CLI args that pin the V8 heap. When the user already
* pinned the heap via NODE_OPTIONS, return `[]` so we do NOT inject a
* conflicting/shadowing CLI `--max-old-space-size` (CLI args override
* NODE_OPTIONS, which would re-introduce #5238). Otherwise return the calibrated
* flag — NODE_OPTIONS already carries the same value, so this stays redundant
* (identical value), never conflicting.
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
* @returns {string[]}
*/
export function buildNodeHeapArgs(env = process.env, memoryLimit) {
return envHasExplicitHeapFlag(env) ? [] : [`${MAX_OLD_SPACE_FLAG}=${memoryLimit}`];
}
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
*/
export function resolveRuntimePorts(fromEnv = process.env) {
const basePort = parsePort(fromEnv.PORT || "20128", 20128);
const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort);
return { basePort, apiPort, dashboardPort };
}
export function withRuntimePortEnv(env, runtimePorts) {
const { basePort, apiPort, dashboardPort } = runtimePorts;
return {
...env,
OMNIROUTE_PORT: String(basePort),
PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort),
};
}
export function sanitizeColorEnv(env = {}) {
const sanitized = { ...env };
// Node warns when both FORCE_COLOR and NO_COLOR are set.
// Prefer NO_COLOR in test tooling to avoid noisy process warnings.
if (typeof sanitized.FORCE_COLOR !== "undefined" && typeof sanitized.NO_COLOR !== "undefined") {
delete sanitized.FORCE_COLOR;
}
return sanitized;
}
export function spawnWithForwardedSignals(command, args, options = {}) {
const child = spawn(command, args, options);
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
process.on("SIGINT", () => child.kill("SIGINT"));
process.on("SIGTERM", () => child.kill("SIGTERM"));
return child;
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export { getEnvSyncPlan, parseEnvFile, syncEnv } from "../dev/sync-env.mjs";
+64
View File
@@ -0,0 +1,64 @@
import fs from "fs";
import os from "os";
import path from "path";
import { execSync } from "child_process";
const args = process.argv.slice(2);
const fullUninstall = args.includes("--full");
const uninstallAlreadyInProgress =
process.env.OMNIROUTE_SKIP_UNINSTALL_HOOK === "1" ||
process.env.npm_lifecycle_event === "uninstall";
console.log("🛑 OmniRoute Uninstaller");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
// 1. Stop PM2 process if it exists
try {
console.log("Stopping and removing background PM2 processes...");
execSync("pm2 delete omniroute 2>/dev/null", { stdio: "ignore" });
} catch {
// It's perfectly fine if pm2 is not installed or the process doesn't exist.
}
// 2. Local AppData / Config Folder cleanup (Only on Full Uninstall)
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
if (fullUninstall) {
console.log(`🧹 Full Uninstall selected. Erasing database and files at: ${dataDir}`);
try {
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
console.log("✅ Data directory removed.");
} else {
console.log("️ Data directory did not exist. Skipping.");
}
} catch (error) {
console.warn("⚠️ Failed to remove data directory:", error.message);
}
} else {
console.log(`💾 Keeping data files at: ${dataDir} intact.`);
}
// 3. NPM uninstall
if (uninstallAlreadyInProgress) {
console.log("️ npm uninstall is already in progress. Skipping nested uninstall command.");
} else {
console.log("🗑️ Removing npm package...");
try {
execSync("npm uninstall -g omniroute", {
stdio: "inherit",
env: {
...process.env,
OMNIROUTE_SKIP_UNINSTALL_HOOK: "1",
},
});
console.log("\n✅ OmniRoute has been successfully uninstalled from your system.");
if (!fullUninstall) {
console.log(`️ Your configurations and databases were preserved in ${dataDir}.`);
}
} catch (error) {
console.warn(
"⚠️ Failed to remove npm package. You might need to run this command with 'sudo'."
);
}
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
PACK_ARTIFACT_REQUIRED_PATHS,
findMissingArtifactPaths,
findUnexpectedArtifactPaths,
} from "./pack-artifact-policy.ts";
const __filename: string = fileURLToPath(import.meta.url);
const __dirname: string = dirname(__filename);
const ROOT: string = join(__dirname, "..", "..");
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const npmExecPath = process.env.npm_execpath;
const isBunRuntime = "Bun" in globalThis;
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
return execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
}
function ensureAppStagingReady(): void {
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
requiredPath.startsWith("dist/")
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
if (missingAppRequiredPaths.length === 0) return;
console.log("📦 dist/ staging is missing required runtime files; running npm run build:cli...");
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
const jsonPayload =
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
const parsed = JSON.parse(jsonPayload);
const packReport = Array.isArray(parsed) ? parsed[0] : null;
if (!packReport || !Array.isArray(packReport.files)) {
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
}
return packReport;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 1024) {
return `${bytes || 0} B`;
}
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");
try {
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
});
const missingRequiredPaths: string[] = POLICY_ONLY
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
console.log(` Entry count: ${packReport.entryCount}`);
console.log(` Packed size: ${formatBytes(packReport.size)}`);
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
if (unexpectedPaths.length > 0) {
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
for (const unexpectedPath of unexpectedPaths) {
console.error(` - ${unexpectedPath}`);
}
}
if (missingRequiredPaths.length > 0) {
console.error("\n❌ Required runtime files are missing from the npm publish artifact:");
for (const missingPath of missingRequiredPaths) {
console.error(` - ${missingPath}`);
}
}
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
process.exit(1);
}
console.log("\n✅ Pack artifact policy check passed.");
} catch (error) {
console.error(`\n❌ Pack artifact validation failed: ${error.message}`);
process.exit(1);
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* write-build-sha.mjs — HEAD sentinel guard for OmniRoute release builds.
*
* Writes OMNIROUTE_BUILD_SHA (or git rev-parse --short HEAD) into:
* - dist/BUILD_SHA
* - .build/next/standalone/BUILD_SHA (present after `npm run build`)
*
* Exits 1 if the standalone directory does not exist, which guards against
* stale-cache shipping where a previous build artifact is accidentally published
* without a fresh `next build`.
*
* Usage: node scripts/build/write-build-sha.mjs
* Called by: npm run build:release (after npm run build)
*/
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
// Resolve the build SHA: prefer the env var (set by build:release script), fall
// back to running git rev-parse.
function resolveBuildSha() {
if (process.env.OMNIROUTE_BUILD_SHA) {
return process.env.OMNIROUTE_BUILD_SHA.trim();
}
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
}).trim();
} catch (err) {
console.error("[write-build-sha] Could not determine build SHA:", err.message);
process.exit(1);
}
}
const sha = resolveBuildSha();
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
const standaloneDir = path.join(ROOT, NEXT_DIST, "standalone");
const distDir = path.join(ROOT, "dist");
// Guard: standalone must exist (ensures build:release runs after npm run build)
if (!fs.existsSync(standaloneDir)) {
console.error(
`[write-build-sha] FATAL: standalone dir not found: ${standaloneDir}\n` +
` Run \`npm run build\` before \`npm run build:release\`.`
);
process.exit(1);
}
// Write sentinel to the standalone dir (Docker/dev path)
const standaloneSentinel = path.join(standaloneDir, "BUILD_SHA");
fs.writeFileSync(standaloneSentinel, sha + "\n");
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, standaloneSentinel)}`);
// Write sentinel to dist/ (npm publish path) if it exists
if (fs.existsSync(distDir)) {
const distSentinel = path.join(distDir, "BUILD_SHA");
fs.writeFileSync(distSentinel, sha + "\n");
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, distSentinel)}`);
}
console.log(`[write-build-sha] Build SHA: ${sha}`);