Files
OmniRoute/scripts/build/prepare-electron-standalone.mjs
backryun c6c134300b perf(electron): ship optional ML/browser deps as installable packs (#10382)
Stage 7 of issue #10321 moves the optional ML and browser automation dependency closures out of the desktop bundle into checksummed, versioned packs installed on demand through the omniroute packs command.

- scripts/build/optionalPackStaging.mjs stages pack members under .build/optional-packs, creates release tarballs, and emits optional-packs.index.json with per-member SHA-256 checksums.
- scripts/packs provides manifest, install, remove, and verification helpers plus the packs CLI commands.
- Runtime lookup includes installed pack node_modules directories, while LLMLingua and browser executors continue to degrade gracefully when packs are absent.

The measured darwin-arm64 staging closure was about 534 MB of the 929 MB standalone node_modules tree (57%).
2026-08-16 02:20:59 -03:00

220 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
import { stageOptionalPacks } from "./optionalPackStaging.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 });
}
}
}
// Fail the build if hashed native copies survived the cleanup above. Without this,
// a wrong baseDir makes removeNativeModules() a silent no-op (it early-returns when
// the directory does not exist) and the ABI mismatch only surfaces at runtime on a
// user machine as "Internal Server Error" on every route.
function assertNoStaleHashedNatives(baseDir, prefixes) {
if (!existsSync(baseDir)) return;
const leftovers = readdirSync(baseDir).filter((dir) => prefixes.some((p) => dir.startsWith(p)));
if (leftovers.length > 0) {
throw new Error(
`[electron] stale native module copies survived cleanup in ${baseDir}: ` +
`${leftovers.join(", ")}. These carry the plain-Node ABI and shadow the ` +
`Electron-rebuilt binaries at runtime (ERR_DLOPEN_FAILED -> sql.js fallback -> OOM).`
);
}
}
// --- Electron-UNIQUE: verify better-sqlite3 Node-API prebuilds ----------------
//
// better-sqlite3 >= 13 ships Node-API (NAPI_VERSION=10) prebuilds for every
// platform we package (darwin/linux/linuxmusl/win32 × x64/arm64) inside the
// npm tarball. Node-API addons are ABI-independent, so the same prebuild runs
// under plain Node (CI, CLI) and under the packaged app's ELECTRON_RUN_AS_NODE
// server (verified against electron 43 / NODE_MODULE_VERSION 148 — issue
// #10321 Stage 6). The historical source rebuild below existed because
// better-sqlite3@12 only shipped prebuilds up to electron-v146 and electron 43
// (v148) silently got no binary; v13 makes that obsolete.
//
// Instead of compiling from source on every build (tens of seconds to minutes
// per platform), we fail fast when the prebuild for the CURRENT build platform
// is missing — a missing prebuild must kill the build here, not the app on a
// user machine with "Nenhum driver SQLite disponível — better-sqlite3 (falhou)".
function verifyBetterSqlite3Prebuilds(standaloneNodeModules) {
const destMod = join(standaloneNodeModules, "better-sqlite3");
if (!existsSync(destMod)) {
console.warn("[electron] better-sqlite3 not found in standalone — skipping prebuild check.");
return;
}
// Fail fast when the loader would find no prebuild for THIS build platform.
// Mirrors better-sqlite3's own lib/binding.js selection logic.
const reportHeader = process.report?.getReport?.().header;
assertSqlitePrebuildExists(destMod, process.platform, process.arch, reportHeader);
// Drop compile inputs and stale Node-ABI build outputs to keep the packaged
// app lean and to guarantee the loader resolves the prebuild, not a leftover
// build/Release/better_sqlite3.node compiled for a different ABI.
for (const dir of ["build", "deps", "src"]) {
rmSync(join(destMod, dir), { recursive: true, force: true });
}
console.log(
`[electron] better-sqlite3 Node-API prebuilds verified for ${process.platform}-${process.arch}.`
);
}
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,
// Next can emit hashed external package names in instrumentation chunks.
// The standalone dependency tree contains the canonical package names, so
// normalize those imports before electron-builder copies the bundle.
patchTurbopackChunks: true,
copyNatives: true,
// #6724/#6594: dereference Turbopack hashed-module symlinks — inside the packaged
// app they would point at the build machine's absolute paths and break on install.
materializeSymlinks: true,
});
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
if (docsPrune.removedFiles > 0) {
console.log(
`[electron] pruned ${docsPrune.removedFiles} authoring doc file(s) ` +
`(${docsPrune.removedBytes} bytes) from the staging bundle`
);
}
// Electron-UNIQUE post-assembly steps
removeGeneratedElectronArtifacts();
// Verify better-sqlite3 Node-API prebuilds 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 better-sqlite3 under .next/node_modules so it cannot
// shadow the prebuild-backed one.
verifyBetterSqlite3Prebuilds(join(ELECTRON_STANDALONE_DIR, "node_modules"));
removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules"), ["keytar"]);
removeNativeModules(join(ELECTRON_STANDALONE_DIR, NEXT_DIST_DIR, "node_modules"), [
"better-sqlite3",
"keytar",
]);
// Post-condition: the cleanup above must actually have removed the stale Node-ABI
// copies. It silently no-opped across releases because the path was hardcoded to
// ".next" while distDir is ".build/next", so an ABI-mismatched better_sqlite3.node
// shipped inside the installer and the app fell back to sql.js and OOM-ed.
assertNoStaleHashedNatives(join(ELECTRON_STANDALONE_DIR, NEXT_DIST_DIR, "node_modules"), [
"better-sqlite3",
"keytar",
]);
// Stage 7 (issue #10321): move the optional ML/browser dependency closure out of
// the desktop bundle into checksummed, versioned packs under
// `.build/optional-packs/` (+ tarballs) and emit `optional-packs.index.json` at
// the bundle root. Runs after the native-module steps so it only ever sees the
// final staging tree. Fail-open per member (see optionalPackStaging.mjs).
const OPTIONAL_PACKS_OUT_DIR = join(ROOT, ".build", "optional-packs");
await stageOptionalPacks({
stagingRoot: ELECTRON_STANDALONE_DIR,
packsOutDir: OPTIONAL_PACKS_OUT_DIR,
log: (msg) => console.log(msg.replace(/^\[optional-packs\]/, "[electron]")),
});
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);