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 <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-05-07 15:35:21 -04:00
parent e383476014
commit 304a77259a
3 changed files with 109 additions and 8 deletions

View file

@ -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);
}

View file

@ -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';

View file

@ -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`);