diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index be0b624134..c2c11c0194 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -17,6 +17,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir, platform } from "node:os"; +import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -194,9 +195,9 @@ if (!existsSync(serverJs)) { } // ── Pre-flight: verify better-sqlite3 native binary ─────── -// The published binary targets linux-x64. Check both platform match AND -// dlopen — on macOS, dlopen alone may succeed on an incompatible binary -// (false positive), so we check platform first as the primary signal. +// Verify the binary's actual target platform/arch before trusting dlopen. +// This avoids the macOS false positive where a bundled linux-x64 addon can +// appear to load even though the runtime will fail when better-sqlite3 starts. const sqliteBinary = join( APP_DIR, "node_modules", @@ -205,25 +206,15 @@ const sqliteBinary = join( "Release", "better_sqlite3.node" ); -if (existsSync(sqliteBinary)) { - let compatible = false; - try { - process.dlopen({ exports: {} }, sqliteBinary); - compatible = true; - } catch { - // dlopen failed — definitely incompatible - } - - if (!compatible) { - console.error( - "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" - ); - console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`); - if (platform() === "darwin") { - console.error(" If build tools are missing: xcode-select --install"); - } - process.exit(1); +if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { + console.error( + "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" + ); + console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`); + if (platform() === "darwin") { + console.error(" If build tools are missing: xcode-select --install"); } + process.exit(1); } // ── Start server ─────────────────────────────────────────── diff --git a/package.json b/package.json index 994e827649..c07f7503f4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "open-sse/mcp-server/", "src/shared/contracts/", "scripts/postinstall.mjs", + "scripts/native-binary-compat.mjs", "README.md", "LICENSE" ], diff --git a/scripts/native-binary-compat.mjs b/scripts/native-binary-compat.mjs new file mode 100644 index 0000000000..1b5ab353e7 --- /dev/null +++ b/scripts/native-binary-compat.mjs @@ -0,0 +1,163 @@ +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.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; + } +} diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 449947b2bc..12fd4436e5 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -1,81 +1,115 @@ #!/usr/bin/env node /** - * OmniRoute — Postinstall Native Module Rebuild + * OmniRoute — Postinstall Native Module Fix * * The npm package ships with a Next.js standalone build that includes - * better-sqlite3 compiled for the build platform (Linux x64). - * This script detects platform mismatches and rebuilds the native - * module for the user's actual OS/architecture. + * better-sqlite3 compiled for the build platform (Linux x64) inside + * app/node_modules/. However, npm also installs better-sqlite3 as a + * top-level dependency (in the root node_modules/), correctly compiled + * for the user's platform. + * + * This script copies the correctly-built native binary from the root + * into the standalone app directory — no rebuild or build tools needed. * * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129 + * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321 */ -import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, copyFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { PUBLISHED_BUILD_PLATFORM, PUBLISHED_BUILD_ARCH } from "./native-binary-compat.mjs"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, ".."); -// The standalone build bundles better-sqlite3 inside app/node_modules -const appNodeModules = join(ROOT, "app", "node_modules", "better-sqlite3"); +const appBinary = join( + ROOT, + "app", + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); +const rootBinary = join( + ROOT, + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); -if (!existsSync(appNodeModules)) { - // No bundled better-sqlite3 — nothing to do (dev install, not npm global) +if (!existsSync(join(ROOT, "app", "node_modules", "better-sqlite3"))) { process.exit(0); } -const buildInfoPath = join(appNodeModules, "build", "Release", "better_sqlite3.node"); +const platformMatch = + process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH; -// The published binary is compiled for linux-x64. -// On any other platform/arch, we must rebuild — dlopen alone is unreliable -// because macOS may load an incompatible binary without throwing. -const BUILD_PLATFORM = "linux"; -const BUILD_ARCH = "x64"; -const needsRebuild = process.platform !== BUILD_PLATFORM || process.arch !== BUILD_ARCH; - -if (!needsRebuild) { +if (platformMatch) { try { - process.dlopen({ exports: {} }, buildInfoPath); + process.dlopen({ exports: {} }, appBinary); process.exit(0); - } catch { - // Same platform but binary still incompatible (e.g. Node.js ABI mismatch) — rebuild + } catch (err) { + console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`); } } -console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`); +console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`); + +// Strategy 1: Copy the correctly-built binary from root node_modules +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"); + process.exit(0); + } catch (err) { + console.warn(` ⚠️ Copied binary failed to load: ${err.message}`); + } +} + +// Strategy 2: Fall back to npm rebuild (may work if build tools are available) +console.log(" ⚠️ Root binary not available or incompatible, attempting npm rebuild..."); try { + const { execSync } = await import("node:child_process"); execSync("npm rebuild better-sqlite3", { cwd: join(ROOT, "app"), stdio: "inherit", timeout: 120_000, }); -} catch (error) { - console.error(" ❌ Failed to rebuild better-sqlite3 automatically."); - console.error(" You can fix this manually by running:"); - console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`); - if (process.platform === "darwin") { - console.error(" If build tools are missing: xcode-select --install"); + + process.dlopen({ exports: {} }, appBinary); + console.log(" ✅ Native module rebuilt successfully!\n"); + process.exit(0); +} catch (err) { + const isTimeout = err.killed || err.signal === "SIGTERM"; + if (isTimeout) { + console.warn(" ⚠️ npm rebuild timed out after 120s."); + } else { + console.warn(` ⚠️ npm rebuild failed: ${err.message}`); } - console.error(""); - process.exit(1); } -// Verify the rebuilt binary actually loads -try { - process.dlopen({ exports: {} }, buildInfoPath); - console.log(" ✅ Native module rebuilt successfully!\n"); -} catch { - console.error(" ❌ Rebuild completed but binary is still incompatible."); - console.error(" Try manually:"); - console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`); - if (process.platform === "darwin") { - console.error(" If build tools are missing: xcode-select --install"); - } - console.error(""); - process.exit(1); +// If nothing worked, warn but don't fail the install — let the package stay +// installed so users can fix manually or use the pre-flight check in the CLI +console.warn(" ⚠️ Could not fix better-sqlite3 native module automatically."); +console.warn(" The server may not start correctly."); +console.warn(" Try manually:"); +console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`); +if (process.platform === "darwin") { + console.warn(" If build tools are missing: xcode-select --install"); } +console.warn(""); diff --git a/tests/unit/native-binary-compat.test.mjs b/tests/unit/native-binary-compat.test.mjs new file mode 100644 index 0000000000..73d0a86bd7 --- /dev/null +++ b/tests/unit/native-binary-compat.test.mjs @@ -0,0 +1,143 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + detectNativeBinaryTarget, + isNativeBinaryCompatible, +} from "../../scripts/native-binary-compat.mjs"; + +function makeElfBinary(machine) { + const buffer = Buffer.alloc(64); + buffer[0] = 0x7f; + buffer[1] = 0x45; + buffer[2] = 0x4c; + buffer[3] = 0x46; + buffer[4] = 2; + buffer[5] = 1; + buffer.writeUInt16LE(machine, 18); + return buffer; +} + +function makeMachBinary(cpuType) { + const buffer = Buffer.alloc(32); + buffer.writeUInt32BE(0xcffaedfe, 0); + buffer.writeUInt32LE(cpuType, 4); + return buffer; +} + +function makePeBinary(machine) { + const buffer = Buffer.alloc(160); + buffer[0] = 0x4d; + buffer[1] = 0x5a; + buffer.writeUInt32LE(0x80, 0x3c); + buffer.write("PE\0\0", 0x80, "ascii"); + buffer.writeUInt16LE(machine, 0x84); + return buffer; +} + +describe("detectNativeBinaryTarget", () => { + it("detects linux x64 ELF binaries", () => { + assert.deepEqual(detectNativeBinaryTarget(makeElfBinary(62)), { + platform: "linux", + architectures: ["x64"], + }); + }); + + it("detects darwin arm64 Mach-O binaries", () => { + assert.deepEqual(detectNativeBinaryTarget(makeMachBinary(0x0100000c)), { + platform: "darwin", + architectures: ["arm64"], + }); + }); + + it("detects win32 x64 PE binaries", () => { + assert.deepEqual(detectNativeBinaryTarget(makePeBinary(0x8664)), { + platform: "win32", + architectures: ["x64"], + }); + }); +}); + +describe("isNativeBinaryCompatible", () => { + function withTempBinary(buffer, callback) { + const dir = mkdtempSync(join(tmpdir(), "omniroute-native-")); + const file = join(dir, "better_sqlite3.node"); + writeFileSync(file, buffer); + + try { + callback(file); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("accepts linux-x64 binaries when the target matches and dlopen succeeds", () => { + withTempBinary(makeElfBinary(62), (binaryPath) => { + assert.equal( + isNativeBinaryCompatible(binaryPath, { + runtimePlatform: "linux", + runtimeArch: "x64", + dlopen() {}, + }), + true + ); + }); + }); + + it("rejects linux-x64 binaries when dlopen fails on the same platform", () => { + withTempBinary(makeElfBinary(62), (binaryPath) => { + assert.equal( + isNativeBinaryCompatible(binaryPath, { + runtimePlatform: "linux", + runtimeArch: "x64", + dlopen() { + throw new Error("abi mismatch"); + }, + }), + false + ); + }); + }); + + it("rejects macOS false positives for bundled linux binaries", () => { + withTempBinary(makeElfBinary(62), (binaryPath) => { + assert.equal( + isNativeBinaryCompatible(binaryPath, { + runtimePlatform: "darwin", + runtimeArch: "arm64", + dlopen() {}, + }), + false + ); + }); + }); + + it("rejects Windows false positives for bundled linux binaries", () => { + withTempBinary(makeElfBinary(62), (binaryPath) => { + assert.equal( + isNativeBinaryCompatible(binaryPath, { + runtimePlatform: "win32", + runtimeArch: "x64", + dlopen() {}, + }), + false + ); + }); + }); + + it("accepts copied darwin binaries after postinstall replacement", () => { + withTempBinary(makeMachBinary(0x0100000c), (binaryPath) => { + assert.equal( + isNativeBinaryCompatible(binaryPath, { + runtimePlatform: "darwin", + runtimeArch: "arm64", + dlopen() {}, + }), + true + ); + }); + }); +});