Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -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-<hash>`), 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)
26 changes: 26 additions & 0 deletions scripts/build/lib/removeNativeModules.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
}
}
20 changes: 5 additions & 15 deletions scripts/build/prepare-electron-standalone.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
163 changes: 163 additions & 0 deletions tests/unit/build/prepare-electron-standalone.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});