mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301)
Integrated into release/v3.8.13
This commit is contained in:
114
scripts/dev/ensure-native-sqlite.mjs
Normal file
114
scripts/dev/ensure-native-sqlite.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Dev-startup native SQLite ABI guard.
|
||||
*
|
||||
* `better-sqlite3` is a native addon compiled for a specific Node.js ABI
|
||||
* (NODE_MODULE_VERSION). This project supports both Node 22 (ABI 127) and
|
||||
* Node 24 (ABI 137); switching between them via nvm leaves the previously
|
||||
* built `better_sqlite3.node` incompatible, so `npm run dev` crashes during
|
||||
* bootstrap with:
|
||||
*
|
||||
* "The module '…/better_sqlite3.node' was compiled against a different
|
||||
* Node.js version using NODE_MODULE_VERSION 127. This version of Node.js
|
||||
* requires NODE_MODULE_VERSION 137."
|
||||
*
|
||||
* `postinstall.mjs` only fixes the published standalone bundle and only runs
|
||||
* on `npm install` — it does NOT cover "cloned repo, switched Node, ran dev".
|
||||
*
|
||||
* This guard probes the root binary against the *current* Node ABI and, ONLY
|
||||
* when it detects a genuine ABI mismatch, runs `npm rebuild better-sqlite3`
|
||||
* once. The healthy path (matching ABI) does no work, so dev startup stays
|
||||
* fast. Unrelated errors are NOT swallowed — they fall through so the normal
|
||||
* bootstrap surfaces them.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
|
||||
export const SQLITE_BINARY = join(
|
||||
ROOT,
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether an error message indicates a native-addon ABI / load mismatch
|
||||
* (as opposed to an unrelated runtime error such as a missing table).
|
||||
* Mirrors the detection in src/lib/db/core.ts::isNativeSqliteLoadError.
|
||||
* @param {unknown} message
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isNativeAbiMismatch(message) {
|
||||
const m = String(message ?? "");
|
||||
return (
|
||||
m.includes("NODE_MODULE_VERSION") ||
|
||||
m.includes("was compiled against a different Node.js version") ||
|
||||
m.includes("Module did not self-register") ||
|
||||
m.includes("ERR_DLOPEN_FAILED") ||
|
||||
m.includes("Could not locate the bindings file")
|
||||
);
|
||||
}
|
||||
|
||||
/** Probe a native binary against the current Node ABI without polluting the require cache. */
|
||||
function probeLoad(binaryPath) {
|
||||
process.dlopen({ exports: {} }, binaryPath);
|
||||
}
|
||||
|
||||
/** Default rebuild: `npm rebuild better-sqlite3` at the repo root (no shell interpolation). */
|
||||
function defaultRebuild() {
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const result = spawnSync(npm, ["rebuild", "better-sqlite3"], { cwd: ROOT, stdio: "inherit" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure better-sqlite3 loads under the current Node. Rebuilds once on ABI
|
||||
* mismatch. Returns a result object; never throws for the mismatch path.
|
||||
*
|
||||
* @param {{ logger?: Pick<Console,"warn"|"error"|"log">, rebuild?: () => boolean, probe?: (p: string) => void, binaryPath?: string }} [opts]
|
||||
* @returns {{ ok: boolean, rebuilt: boolean, error?: unknown }}
|
||||
*/
|
||||
export function ensureNativeSqlite(opts = {}) {
|
||||
const {
|
||||
logger = console,
|
||||
rebuild = defaultRebuild,
|
||||
probe = probeLoad,
|
||||
binaryPath = SQLITE_BINARY,
|
||||
} = opts;
|
||||
|
||||
// Nothing built yet (fresh clone before install) — let install/bootstrap handle it.
|
||||
if (!existsSync(binaryPath)) return { ok: true, rebuilt: false };
|
||||
|
||||
try {
|
||||
probe(binaryPath);
|
||||
return { ok: true, rebuilt: false };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isNativeAbiMismatch(message)) {
|
||||
// Not an ABI problem — do not mask it; bootstrap will surface the real error.
|
||||
return { ok: false, rebuilt: false, error };
|
||||
}
|
||||
logger.warn(
|
||||
`[dev] better-sqlite3 was built for a different Node ABI than ${process.version} — ` +
|
||||
"rebuilding (one-time)…"
|
||||
);
|
||||
if (!rebuild()) {
|
||||
logger.error(
|
||||
"[dev] Automatic 'npm rebuild better-sqlite3' failed. Run it manually:\n" +
|
||||
" npm rebuild better-sqlite3"
|
||||
);
|
||||
return { ok: false, rebuilt: false };
|
||||
}
|
||||
logger.log("[dev] better-sqlite3 rebuilt for the current Node. Continuing startup.");
|
||||
return { ok: true, rebuilt: true };
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj
|
||||
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
|
||||
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
|
||||
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
|
||||
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
|
||||
@@ -36,6 +37,12 @@ if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) {
|
||||
const mode = process.argv[2] === "start" ? "start" : "dev";
|
||||
const dev = mode === "dev";
|
||||
|
||||
// Self-heal a stale better-sqlite3 native binary after a Node version switch
|
||||
// (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches.
|
||||
if (dev) {
|
||||
ensureNativeSqlite();
|
||||
}
|
||||
|
||||
const bootstrappedEnv = bootstrapEnv();
|
||||
const runtimePorts = resolveRuntimePorts(bootstrappedEnv);
|
||||
const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts);
|
||||
|
||||
112
tests/unit/dev-ensure-native-sqlite.test.ts
Normal file
112
tests/unit/dev-ensure-native-sqlite.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ensureNativeSqlite,
|
||||
isNativeAbiMismatch,
|
||||
} from "../../scripts/dev/ensure-native-sqlite.mjs";
|
||||
|
||||
// A binary path that is guaranteed to exist so the existsSync() guard passes;
|
||||
// the injected probe controls the actual outcome.
|
||||
const EXISTING_PATH = process.execPath;
|
||||
const silentLogger = { warn() {}, error() {}, log() {} };
|
||||
|
||||
// The exact message a Node 24 process produces against a Node 22 (ABI 127) binary.
|
||||
const ABI_ERROR =
|
||||
"The module '/x/node_modules/better-sqlite3/build/Release/better_sqlite3.node' " +
|
||||
"was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " +
|
||||
"This version of Node.js requires NODE_MODULE_VERSION 137.";
|
||||
|
||||
test("isNativeAbiMismatch detects ABI / native-load errors", () => {
|
||||
assert.equal(isNativeAbiMismatch(ABI_ERROR), true);
|
||||
assert.equal(isNativeAbiMismatch("Module did not self-register"), true);
|
||||
assert.equal(isNativeAbiMismatch("ERR_DLOPEN_FAILED: bad bits"), true);
|
||||
assert.equal(isNativeAbiMismatch("Could not locate the bindings file"), true);
|
||||
});
|
||||
|
||||
test("isNativeAbiMismatch ignores unrelated errors", () => {
|
||||
assert.equal(isNativeAbiMismatch("SQLITE_ERROR: no such table: foo"), false);
|
||||
assert.equal(isNativeAbiMismatch("ENOENT: no such file"), false);
|
||||
assert.equal(isNativeAbiMismatch(""), false);
|
||||
assert.equal(isNativeAbiMismatch(null), false);
|
||||
assert.equal(isNativeAbiMismatch(undefined), false);
|
||||
});
|
||||
|
||||
test("ensureNativeSqlite: healthy binary does nothing (fast path)", () => {
|
||||
let rebuilt = 0;
|
||||
const res = ensureNativeSqlite({
|
||||
logger: silentLogger,
|
||||
binaryPath: EXISTING_PATH,
|
||||
probe: () => {
|
||||
/* loads fine */
|
||||
},
|
||||
rebuild: () => {
|
||||
rebuilt++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { ok: true, rebuilt: false });
|
||||
assert.equal(rebuilt, 0, "must not rebuild when the ABI already matches");
|
||||
});
|
||||
|
||||
test("ensureNativeSqlite: ABI mismatch triggers exactly one rebuild", () => {
|
||||
let rebuilt = 0;
|
||||
const res = ensureNativeSqlite({
|
||||
logger: silentLogger,
|
||||
binaryPath: EXISTING_PATH,
|
||||
probe: () => {
|
||||
throw new Error(ABI_ERROR);
|
||||
},
|
||||
rebuild: () => {
|
||||
rebuilt++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.rebuilt, true);
|
||||
assert.equal(rebuilt, 1, "rebuild must run once on ABI mismatch");
|
||||
});
|
||||
|
||||
test("ensureNativeSqlite: failed rebuild reports ok=false", () => {
|
||||
const res = ensureNativeSqlite({
|
||||
logger: silentLogger,
|
||||
binaryPath: EXISTING_PATH,
|
||||
probe: () => {
|
||||
throw new Error(ABI_ERROR);
|
||||
},
|
||||
rebuild: () => false,
|
||||
});
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.rebuilt, false);
|
||||
});
|
||||
|
||||
test("ensureNativeSqlite: unrelated load error is NOT swallowed and does not rebuild", () => {
|
||||
let rebuilt = 0;
|
||||
const res = ensureNativeSqlite({
|
||||
logger: silentLogger,
|
||||
binaryPath: EXISTING_PATH,
|
||||
probe: () => {
|
||||
throw new Error("SQLITE_CANTOPEN: unable to open database file");
|
||||
},
|
||||
rebuild: () => {
|
||||
rebuilt++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.rebuilt, false);
|
||||
assert.ok(res.error instanceof Error);
|
||||
assert.equal(rebuilt, 0, "must not rebuild for unrelated errors");
|
||||
});
|
||||
|
||||
test("ensureNativeSqlite: missing binary is a no-op (pre-install)", () => {
|
||||
const res = ensureNativeSqlite({
|
||||
logger: silentLogger,
|
||||
binaryPath: "/path/that/does/not/exist/better_sqlite3.node",
|
||||
probe: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
rebuild: () => true,
|
||||
});
|
||||
assert.deepEqual(res, { ok: true, rebuilt: false });
|
||||
});
|
||||
Reference in New Issue
Block a user