Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 12 additions & 21 deletions bin/omniroute.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -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 ───────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"open-sse/mcp-server/",
"src/shared/contracts/",
"scripts/postinstall.mjs",
"scripts/native-binary-compat.mjs",
"README.md",
"LICENSE"
],
Expand Down
163 changes: 163 additions & 0 deletions scripts/native-binary-compat.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
120 changes: 77 additions & 43 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -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;

if (platformMatch) {
try {
process.dlopen({ exports: {} }, appBinary);
process.exit(0);
} catch (err) {
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
}
}

console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.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;
// 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}`);
}

if (!needsRebuild) {
try {
process.dlopen({ exports: {} }, buildInfoPath);
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module fixed successfully!\n");
process.exit(0);
} catch {
// Same platform but binary still incompatible (e.g. Node.js ABI mismatch) — rebuild
} catch (err) {
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
}
Comment on lines +67 to 80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current structure with two separate try...catch blocks can lead to confusing log messages. If copyFileSync fails, the script will still attempt to dlopen the (likely incompatible or non-existent) binary in appBinary and log 'Copied binary failed to load', which is misleading. Nesting the dlopen check inside the try block for the file copy would make the logic more robust and the error reporting clearer.

  try {
    mkdirSync(dirname(appBinary), { recursive: true });
    copyFileSync(rootBinary, appBinary);
    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}`);
    }
  } catch (err) {
    console.warn(`  ⚠️  Failed to copy binary: ${err.message}`);
  }

}

console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
// 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");
}
console.error("");
process.exit(1);
}

// Verify the rebuilt binary actually loads
try {
process.dlopen({ exports: {} }, buildInfoPath);
process.dlopen({ exports: {} }, appBinary);
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");
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);
}
Comment on lines 86 to 104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the copy logic, this try...catch block combines the execSync call for rebuilding and the dlopen verification. If the rebuild succeeds but dlopen fails, the error message 'npm rebuild failed' is inaccurate. Separating these into nested try...catch blocks will provide more precise feedback to the user about which step actually failed.

try {
  const { execSync } = await import("node:child_process");
  execSync("npm rebuild better-sqlite3", {
    cwd: join(ROOT, "app"),
    stdio: "inherit",
    timeout: 120_000,
  });

  try {
    process.dlopen({ exports: {} }, appBinary);
    console.log("  ✅ Native module rebuilt successfully!\n");
    process.exit(0);
  } catch (err) {
    console.warn(`  ⚠️  Rebuilt binary failed to load: ${err.message}`);
  }
} 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}`);
  }
}


// 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("");
Loading
Loading