From 304a77259a1dee55f969cd09fb58de6de13f2cb5 Mon Sep 17 00:00:00 2001 From: ruvnet Date: Thu, 7 May 2026 15:35:21 -0400 Subject: [PATCH] fix(ruvector): Node 22 LTS-compatible ONNX WASM loader (#323) The bundled `pkg/ruvector_onnx_embeddings_wasm.js` is wasm-bindgen's `--target bundler` output. Its first line is import * as wasm from "./ruvector_onnx_embeddings_wasm_bg.wasm"; Node 22 LTS rejects static `.wasm` imports without `--experimental-wasm-modules`, throwing `Unknown file extension ".wasm"` and tearing down `initOnnxEmbedder()` before any user code runs. Node 25+ accepts it natively, which masked the regression. Add a Node-friendly sibling entry that uses `fs.readFileSync` + `WebAssembly.compile/instantiate` and routes the host imports through the existing `_bg.js` (the import module name `./*_bg.js` was verified by inspecting the .wasm import section). The bundler-target file is left untouched so webpack/vite/rollup consumers and `--target bundler`-style toolchains keep working. `onnx-embedder.ts` now prefers the new `_node.mjs` entry when present and falls back to the bundler entry, so older bundles stay loadable. Verified on Node v22.22.2: $ node test/onnx-node22-loader.test.mjs ok: Node-friendly loader exists in src/core/onnx/pkg/ ok: loader produced a module namespace ok: re-exports __wbg_set_wasm ok: re-exports WasmEmbedder ok: re-exports WasmEmbedderConfig Node v22.22.2 ONNX loader smoke OK Note: end-to-end `initOnnxEmbedder()` from a clean install also requires the build script to copy `src/core/onnx/pkg/` into `dist/` (tracked separately as #354 / #417). This PR is the loader fix only. Closes #323 Co-Authored-By: claude-flow --- .../ruvector/src/core/onnx-embedder.ts | 26 ++++++--- .../ruvector_onnx_embeddings_wasm_node.mjs | 37 +++++++++++++ .../ruvector/test/onnx-node22-loader.test.mjs | 54 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 npm/packages/ruvector/src/core/onnx/pkg/ruvector_onnx_embeddings_wasm_node.mjs create mode 100644 npm/packages/ruvector/test/onnx-node22-loader.test.mjs diff --git a/npm/packages/ruvector/src/core/onnx-embedder.ts b/npm/packages/ruvector/src/core/onnx-embedder.ts index 491195e8b..37080d1ff 100644 --- a/npm/packages/ruvector/src/core/onnx-embedder.ts +++ b/npm/packages/ruvector/src/core/onnx-embedder.ts @@ -97,8 +97,11 @@ const DEFAULT_MODEL = 'all-MiniLM-L6-v2'; */ export function isOnnxAvailable(): boolean { try { + // Prefer the Node-friendly loader (#323); fall back to the bundler entry + // for older bundles that don't ship the .mjs sibling yet. + const nodePkgPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm_node.mjs'); const pkgPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm.js'); - return fs.existsSync(pkgPath); + return fs.existsSync(nodePkgPath) || fs.existsSync(pkgPath); } catch { return false; } @@ -176,10 +179,16 @@ export async function initOnnxEmbedder(config: OnnxEmbedderConfig = {}): Promise loadPromise = (async () => { try { - // Paths to bundled ONNX files - const pkgPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm.js'); + // Prefer the Node-friendly loader (resolves issue #323 — the + // bundler-target index does `import * as wasm from "./*.wasm"`, + // which Node 22 LTS rejects without --experimental-wasm-modules). + // Fall back to the bundler entry only if the .mjs is absent (older + // bundles), so existing installs aren't broken by this change. + const nodePkgPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm_node.mjs'); + const bundlerPkgPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm.js'); const loaderPath = path.join(__dirname, 'onnx', 'loader.js'); + const pkgPath = fs.existsSync(nodePkgPath) ? nodePkgPath : bundlerPkgPath; if (!fs.existsSync(pkgPath)) { throw new Error('ONNX WASM files not bundled. The onnx/ directory is missing.'); } @@ -188,13 +197,14 @@ export async function initOnnxEmbedder(config: OnnxEmbedderConfig = {}): Promise const pkgUrl = pathToFileURL(pkgPath).href; const loaderUrl = pathToFileURL(loaderPath).href; - // Dynamic import of bundled modules using file:// URLs + // Dynamic import of bundled modules using file:// URLs. + // The Node loader instantiates the WASM at module-load time via + // fs.readFileSync, so no separate default() init is needed. wasmModule = await dynamicImport(pkgUrl); - // Initialize WASM module (loads the .wasm file) - const wasmPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm_bg.wasm'); - if (wasmModule.default && typeof wasmModule.default === 'function') { - // For bundler-style initialization, pass the wasm buffer + // Legacy bundler-target path still needs an explicit init(). + if (pkgPath === bundlerPkgPath && wasmModule.default && typeof wasmModule.default === 'function') { + const wasmPath = path.join(__dirname, 'onnx', 'pkg', 'ruvector_onnx_embeddings_wasm_bg.wasm'); const wasmBytes = fs.readFileSync(wasmPath); await wasmModule.default(wasmBytes); } diff --git a/npm/packages/ruvector/src/core/onnx/pkg/ruvector_onnx_embeddings_wasm_node.mjs b/npm/packages/ruvector/src/core/onnx/pkg/ruvector_onnx_embeddings_wasm_node.mjs new file mode 100644 index 000000000..43920ec7a --- /dev/null +++ b/npm/packages/ruvector/src/core/onnx/pkg/ruvector_onnx_embeddings_wasm_node.mjs @@ -0,0 +1,37 @@ +// Node-friendly entry for the wasm-bindgen output (regression guard for #323). +// +// Background: the autogenerated `ruvector_onnx_embeddings_wasm.js` is the +// `--target bundler` output, whose first line is +// import * as wasm from "./ruvector_onnx_embeddings_wasm_bg.wasm"; +// Node 22 LTS rejects static `.wasm` imports without +// `--experimental-wasm-modules`, throwing `Unknown file extension ".wasm"`. +// This file replaces that load path with `fs.readFileSync` + +// `WebAssembly.instantiate`, which works on Node 18+ unchanged. +// +// The bundler-target file is left alone so browser/webpack/vite consumers +// keep working with their existing toolchains. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as bg from './ruvector_onnx_embeddings_wasm_bg.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const wasmPath = join(__dirname, 'ruvector_onnx_embeddings_wasm_bg.wasm'); + +const bytes = readFileSync(wasmPath); +const compiled = await WebAssembly.compile(bytes); + +// wasm-bindgen labels the host-import module by the bg.js path; keep this +// key in sync if the binary is ever regenerated with a different name. +const instance = await WebAssembly.instantiate(compiled, { + './ruvector_onnx_embeddings_wasm_bg.js': bg, +}); + +bg.__wbg_set_wasm(instance.exports); +if (typeof instance.exports.__wbindgen_start === 'function') { + instance.exports.__wbindgen_start(); +} + +export * from './ruvector_onnx_embeddings_wasm_bg.js'; diff --git a/npm/packages/ruvector/test/onnx-node22-loader.test.mjs b/npm/packages/ruvector/test/onnx-node22-loader.test.mjs new file mode 100644 index 000000000..049759438 --- /dev/null +++ b/npm/packages/ruvector/test/onnx-node22-loader.test.mjs @@ -0,0 +1,54 @@ +// Regression test for issue #323 — ONNX embedder failed on Node 22 LTS +// because `pkg/ruvector_onnx_embeddings_wasm.js` (the wasm-bindgen +// `--target bundler` output) does `import * as wasm from "./*.wasm"`, +// which Node 22 rejects without `--experimental-wasm-modules`. +// +// This test loads the new Node-friendly entry that uses +// `fs.readFileSync` + `WebAssembly.instantiate` and asserts the bindings +// surface looks healthy. Run with: +// +// node test/onnx-node22-loader.test.mjs + +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..'); +const nodeLoader = join(repoRoot, 'src/core/onnx/pkg/ruvector_onnx_embeddings_wasm_node.mjs'); + +let failures = 0; +function check(cond, msg) { + if (!cond) { + console.error('FAIL:', msg); + failures++; + } else { + console.log(' ok:', msg); + } +} + +console.log('Node version:', process.version); +console.log('Loader path:', nodeLoader); + +check(existsSync(nodeLoader), 'Node-friendly loader exists in src/core/onnx/pkg/'); + +// The actual smoke: dynamically import the loader. If Node still rejected +// the .wasm static import this would throw `Unknown file extension ".wasm"` +// and the test would fail. +const mod = await import(nodeLoader); + +check(typeof mod === 'object', 'loader produced a module namespace'); + +// wasm-bindgen `--target bundler` re-exports everything via _bg.js, so the +// loader should re-export the same surface. Pick a couple of well-known +// wasm-bindgen runtime exports as a sanity check. +const expectedSymbols = ['__wbg_set_wasm', 'WasmEmbedder', 'WasmEmbedderConfig']; +for (const sym of expectedSymbols) { + check(typeof mod[sym] !== 'undefined', `re-exports ${sym}`); +} + +if (failures > 0) { + console.error(`\n${failures} check(s) failed`); + process.exit(1); +} +console.log(`\nNode ${process.version} ONNX loader smoke OK`);