diff --git a/changelog.d/fixes/7123-electron-strip-hashed-native-modules.md b/changelog.d/fixes/7123-electron-strip-hashed-native-modules.md new file mode 100644 index 00000000000..49c10831e67 --- /dev/null +++ b/changelog.d/fixes/7123-electron-strip-hashed-native-modules.md @@ -0,0 +1 @@ +- fix(electron): strip hashed native-module duplicates from the Electron standalone and fix the removal path. Enables the already-proven `patchTurbopackChunks` on the Electron packaging step (so server chunks stop `require()`-ing hashed names like `better-sqlite3-`), and points `removeNativeModules()` at the real `NEXT_DIST_DIR` (`.build/next`) instead of a hardcoded `.next` that never matched — so the stale Node-ABI native copies that shadowed the Electron-ABI-rebuilt `better-sqlite3` are actually deleted. Also strips `sqlite-vec`. Fixes the Electron `Internal Server Error` (better-sqlite3 ABI mismatch → sql.js WASM OOM fallback). `removeNativeModules()` extracted to a pure module with unit tests (#7123, closes #7082) diff --git a/scripts/build/lib/removeNativeModules.mjs b/scripts/build/lib/removeNativeModules.mjs new file mode 100644 index 00000000000..b8cf2c4c28e --- /dev/null +++ b/scripts/build/lib/removeNativeModules.mjs @@ -0,0 +1,26 @@ +/** + * Pure helper: remove native modules from a node_modules directory. + * + * Used by prepare-electron-standalone.mjs to strip stale Node-ABI native + * modules that Next.js output tracing copied and that would shadow the + * correctly rebuilt Electron-ABI copies at runtime. + * + * Extracted as a pure, side-effect-free module so it can be unit-tested + * without importing the full Electron build harness. + * + * @param {string} baseDir - directory to scan (e.g. .../node_modules) + * @param {string[]} [prefixes=["keytar"]] - directory name prefixes to remove + */ +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +export 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 }); + } + } +} diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs index da04fca00b6..fe3206507b9 100644 --- a/scripts/build/prepare-electron-standalone.mjs +++ b/scripts/build/prepare-electron-standalone.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node -import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, readFileSync, 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"; import { buildRebuildSpawnPlan } from "./electronRebuildPlan.mjs"; +import { removeNativeModules } from "./lib/removeNativeModules.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -70,19 +71,6 @@ function removeGeneratedElectronArtifacts() { } } -// --- 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 @@ -177,6 +165,7 @@ assembleStandalone({ outDir: ELECTRON_STANDALONE_DIR, projectRoot: ROOT, sanitizePaths: true, + 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. @@ -193,9 +182,10 @@ removeGeneratedElectronArtifacts(); // 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"), [ +removeNativeModules(join(ELECTRON_STANDALONE_DIR, NEXT_DIST_DIR, "node_modules"), [ "better-sqlite3", "keytar", + "sqlite-vec", ]); console.log( diff --git a/tests/unit/build/prepare-electron-standalone.test.ts b/tests/unit/build/prepare-electron-standalone.test.ts new file mode 100644 index 00000000000..77890df0821 --- /dev/null +++ b/tests/unit/build/prepare-electron-standalone.test.ts @@ -0,0 +1,163 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { removeNativeModules } from "../../../scripts/build/lib/removeNativeModules.mjs"; + +/** + * Creates a fake node_modules directory with mixed modules — + * some that should be removed (native modules matching prefixes) + * and some that should be preserved (regular npm packages). + */ +function seedNodeModules(baseDir: string) { + const keep = [ + "@swc/helpers/package.json", + "playwright-core/index.js", + "zod/package.json", + "pino/index.js", + "typescript/package.json", + "ws/package.json", + ]; + for (const rel of keep) { + const full = path.join(baseDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, `// ${rel}`); + } +} + +function seedHashedNativeModules(baseDir: string) { + const remove = [ + "better-sqlite3-90e2652d1716b047/build/Release/better_sqlite3.node", + "better-sqlite3-a1b2c3d4e5f6a7b8/build/Release/better_sqlite3.node", + "keytar-eb44cd511463a26b/build/Release/keytar.node", + "sqlite-vec-63899bd51e44e247/build/Release/vec0.node", + ]; + for (const rel of remove) { + const full = path.join(baseDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, `// ${rel}`); + } +} + +/** Verify that a directory still exists (was NOT removed). */ +function assertExists(dir: string, label: string) { + assert.ok(fs.existsSync(dir), `expected ${label} to still exist`); +} + +/** Verify that a directory was removed. */ +function assertRemoved(dir: string, label: string) { + assert.ok(!fs.existsSync(dir), `expected ${label} to have been removed`); +} + +// ── removeNativeModules() tests ─────────────────────────────────────── + +test("removeNativeModules: no-op when baseDir does not exist", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const nonexistent = path.join(tmp, "does-not-exist"); + + // Must not throw + assert.doesNotThrow(() => { + removeNativeModules(nonexistent); + }); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("removeNativeModules: no-op when baseDir is empty", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const emptyDir = path.join(tmp, "empty"); + fs.mkdirSync(emptyDir); + + assert.doesNotThrow(() => { + removeNativeModules(emptyDir); + }); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("removeNativeModules: removes hashed native modules with custom prefixes", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const nodeModules = path.join(tmp, "node_modules"); + fs.mkdirSync(nodeModules, { recursive: true }); + + seedNodeModules(nodeModules); + seedHashedNativeModules(nodeModules); + + removeNativeModules(nodeModules, ["better-sqlite3", "keytar", "sqlite-vec"]); + + // Hashed native modules should be removed + assertRemoved(path.join(nodeModules, "better-sqlite3-90e2652d1716b047"), "better-sqlite3-90e2652d1716b047"); + assertRemoved(path.join(nodeModules, "better-sqlite3-a1b2c3d4e5f6a7b8"), "better-sqlite3-a1b2c3d4e5f6a7b8"); + assertRemoved(path.join(nodeModules, "keytar-eb44cd511463a26b"), "keytar-eb44cd511463a26b"); + assertRemoved(path.join(nodeModules, "sqlite-vec-63899bd51e44e247"), "sqlite-vec-63899bd51e44e247"); + + // Regular (non-hashed, non-native) modules must be preserved + assertExists(path.join(nodeModules, "@swc"), "@swc"); + assertExists(path.join(nodeModules, "playwright-core"), "playwright-core"); + assertExists(path.join(nodeModules, "zod"), "zod"); + assertExists(path.join(nodeModules, "pino"), "pino"); + assertExists(path.join(nodeModules, "typescript"), "typescript"); + assertExists(path.join(nodeModules, "ws"), "ws"); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("removeNativeModules: removes unhashed native modules matching prefix", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const nodeModules = path.join(tmp, "node_modules"); + fs.mkdirSync(nodeModules, { recursive: true }); + + // Unhashed native modules (installed by root npm install) + fs.mkdirSync(path.join(nodeModules, "better-sqlite3", "build", "Release"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "better-sqlite3", "build", "Release", "better_sqlite3.node"), "// native"); + fs.mkdirSync(path.join(nodeModules, "keytar"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "keytar", "index.js"), "// keytar"); + + removeNativeModules(nodeModules, ["better-sqlite3", "keytar"]); + + // Both should be removed (prefix "better-sqlite3" matches "better-sqlite3", "keytar" matches "keytar") + assertRemoved(path.join(nodeModules, "better-sqlite3"), "better-sqlite3 (unhashed)"); + assertRemoved(path.join(nodeModules, "keytar"), "keytar (unhashed)"); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("removeNativeModules: default prefixes only remove keytar", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const nodeModules = path.join(tmp, "node_modules"); + fs.mkdirSync(nodeModules, { recursive: true }); + + fs.mkdirSync(path.join(nodeModules, "better-sqlite3"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "better-sqlite3", "index.js"), "//"); + fs.mkdirSync(path.join(nodeModules, "keytar"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "keytar", "index.js"), "//"); + + // No custom prefixes → defaults to ["keytar"] only + removeNativeModules(nodeModules); + + assertExists(path.join(nodeModules, "better-sqlite3"), "better-sqlite3 (not in default prefixes)"); + assertRemoved(path.join(nodeModules, "keytar"), "keytar (in default prefixes)"); + + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test("removeNativeModules: preserves non-matching modules with similar names", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "remove-native-")); + const nodeModules = path.join(tmp, "node_modules"); + fs.mkdirSync(nodeModules, { recursive: true }); + + // Modules with names that contain the prefix but don't START with it + fs.mkdirSync(path.join(nodeModules, "x-better-sqlite3"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "x-better-sqlite3", "index.js"), "//"); + fs.mkdirSync(path.join(nodeModules, "@better-sqlite3"), { recursive: true }); + fs.writeFileSync(path.join(nodeModules, "@better-sqlite3", "index.js"), "//"); + + removeNativeModules(nodeModules, ["better-sqlite3"]); + + // These should survive because they don't START with "better-sqlite3" + assertExists(path.join(nodeModules, "x-better-sqlite3"), "x-better-sqlite3 (prefix not at start)"); + assertExists(path.join(nodeModules, "@better-sqlite3"), "@better-sqlite3 (prefix not at start)"); + + fs.rmSync(tmp, { recursive: true, force: true }); +});