From 34096c51d8035c7bdbffce2c86ef7199448fecef Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Fri, 22 May 2026 12:08:02 -0700 Subject: [PATCH 1/3] inline rules-engine WASM binary at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm-bindgen-generated `dist/wasm/rulesengine.js` loads its `.wasm` sibling at runtime via `fs.readFileSync(${__dirname}/rulesengine_bg.wasm)`. That works in plain Node, but breaks the moment a downstream bundler follows the require chain — webpack rewrites `__dirname` to point inside the bundle output, where the `.wasm` sibling never gets copied. Symptom in a Next.js consumer: ENOENT: no such file or directory, open '.next/dev/server/vendor-chunks/rulesengine_bg.wasm' …and the SDK silently falls back to API-only checks, disabling DataStream and credit-lease paths. This adds a build step that reads the WASM binary, base64-encodes it, and rewrites `dist/wasm/rulesengine.js` to instantiate from an inlined `Buffer.from(BASE64, 'base64')` instead of touching the filesystem. The standalone `.wasm` is then removed from `dist/` since nothing reads it at runtime anymore. Tarball delta is +138 KB (base64 overhead on the ~414 KB binary). For consumers that use creditLeases / DataStream the net bundle size is unchanged — the WASM bytes are in either form. For consumers that don't, practical tree-shaking ends up the same regardless of inlining: the require chain is reachable from the package's main entry point and the WASM init runs as a module-level side effect. Materially reducing the WASM cost for non-credit-lease consumers would require splitting credit leases into a separate entry point — out of scope here. If wasm-bindgen output ever stops matching the regex this script keys on, the build throws with a clear pointer instead of silently shipping a broken loader. --- build.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/build.js b/build.js index 7ff25664..d839279b 100644 --- a/build.js +++ b/build.js @@ -1,7 +1,7 @@ // build.js const esbuild = require('esbuild'); const { execSync } = require('child_process'); -const { cpSync, mkdirSync, existsSync } = require('fs'); +const { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } = require('fs'); const sharedConfig = { entryPoints: ['src/index.ts'], @@ -52,6 +52,8 @@ async function build() { } console.log('✅ WASM artifacts copied to dist/wasm/'); + inlineWasmBinary(); + // Generate TypeScript declarations with tsc console.log('🔧 Generating TypeScript declarations...'); execSync('tsc --emitDeclarationOnly --outDir dist', { stdio: 'inherit' }); @@ -65,4 +67,55 @@ async function build() { } } +// Inline the WASM binary into dist/wasm/rulesengine.js so the runtime no +// longer reads it off disk via `fs.readFileSync(${__dirname}/...)`. +// +// The wasm-bindgen-generated loader resolves the .wasm file relative to +// its own `__dirname`, which breaks the moment a downstream bundler +// (webpack, Next.js, Vite, etc.) follows the require chain and rewrites +// `__dirname` to point inside the bundle output — there's no `.wasm` +// sibling there, so initialization fails with a misleading ENOENT. +// See the linked Next.js failure mode at +// .next/dev/server/vendor-chunks/rulesengine_bg.wasm → ENOENT. +// +// Inlining the bytes as a base64 string sidesteps the whole class of +// `__dirname`-aware loaders. Costs ~+138 KB on the tarball (550 KB +// base64 string in JS replaces a 414 KB .wasm + the loader stub) but +// makes the SDK bundler-agnostic for free. We delete the standalone +// `.wasm` after rewriting since nothing reads it at runtime anymore. +function inlineWasmBinary() { + const loaderPath = 'dist/wasm/rulesengine.js'; + const binaryPath = 'dist/wasm/rulesengine_bg.wasm'; + if (!existsSync(loaderPath) || !existsSync(binaryPath)) { + console.warn('⚠️ Skipping WASM inlining — files not found:', { loaderPath, binaryPath }); + return; + } + + const wasmBase64 = readFileSync(binaryPath).toString('base64'); + const loaderSource = readFileSync(loaderPath, 'utf8'); + + // Match the wasm-bindgen-emitted block that reads the binary off disk. + // Captures any whitespace/comments wasm-bindgen emits between the two + // statements so future loader updates don't silently bypass this step. + const loaderPattern = /const wasmPath = `\$\{__dirname\}\/rulesengine_bg\.wasm`;\s*\nconst wasmBytes = require\('fs'\)\.readFileSync\(wasmPath\);/; + if (!loaderPattern.test(loaderSource)) { + throw new Error( + 'WASM inlining failed: expected `const wasmPath = `${__dirname}/...`; const wasmBytes = require(\'fs\').readFileSync(wasmPath);` in ' + + loaderPath + + '. wasm-bindgen output shape changed — update inlineWasmBinary() to match.' + ); + } + + const inlined = loaderSource.replace( + loaderPattern, + `// WASM binary inlined at build time (see build.js inlineWasmBinary).\nconst wasmBytes = Buffer.from('${wasmBase64}', 'base64');` + ); + writeFileSync(loaderPath, inlined); + + // Standalone .wasm is dead weight once the bytes are embedded. Drop + // it so we don't ship two copies of the binary. + rmSync(binaryPath); + console.log(`✅ WASM inlined into ${loaderPath} (${wasmBase64.length} base64 chars, .wasm removed)`); +} + build(); From 7ab9535f071b33a1c83dc1caf1dcef5c5e29c84b Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Fri, 22 May 2026 12:14:03 -0700 Subject: [PATCH 2/3] update verify-package CI check for inlined WASM The verify-package step asserted on `dist/wasm/rulesengine_bg.wasm` presence, which the inlining step now intentionally removes. Replace those checks with a content-sentinel grep on the inlined comment so a future change that silently reverts to disk-based loading still fails CI, plus an explicit assertion that the standalone .wasm is gone. --- .github/workflows/ci.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a1e8091..4eb6961f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,16 +85,25 @@ jobs: echo "ERROR: dist/index.js not found" exit 1 fi - if [ ! -f dist/wasm/rulesengine_bg.wasm ]; then - echo "ERROR: dist/wasm/rulesengine_bg.wasm not found" - echo "The build may have failed to copy WASM artifacts" - exit 1 - fi if [ ! -f dist/wasm/rulesengine.js ]; then echo "ERROR: dist/wasm/rulesengine.js not found" exit 1 fi - echo "Verified: WASM artifacts present in dist/wasm/" + # The .wasm binary is base64-inlined into rulesengine.js at build + # time (see build.js inlineWasmBinary), so the standalone .wasm + # is intentionally absent from dist/. Verify the inlining sentinel + # is present instead — guards against a future build change that + # silently reverts to the disk-loaded path. + if ! grep -q "WASM binary inlined at build time" dist/wasm/rulesengine.js; then + echo "ERROR: dist/wasm/rulesengine.js does not contain the inlined WASM sentinel" + echo "The build may have failed to run inlineWasmBinary()" + exit 1 + fi + if [ -f dist/wasm/rulesengine_bg.wasm ]; then + echo "ERROR: dist/wasm/rulesengine_bg.wasm should have been removed by inlineWasmBinary()" + exit 1 + fi + echo "Verified: WASM inlined into dist/wasm/rulesengine.js" publish: needs: [ compile, test, verify-package ] From d8e475f3d8467fbddbd3de5c5f4e0588ad626d68 Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Wed, 3 Jun 2026 10:12:40 -0700 Subject: [PATCH 3/3] load WASM via dynamic import so non-datastream consumers tree-shake it out --- src/rules-engine.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/rules-engine.ts b/src/rules-engine.ts index ebb7a112..a5337e46 100644 --- a/src/rules-engine.ts +++ b/src/rules-engine.ts @@ -50,12 +50,15 @@ export class RulesEngineClient { } try { - // Dynamic require so the WASM module (which uses fs/path) is never - // pulled into edge/webpack bundles — it only loads in Node.js at runtime. - // The variable indirection prevents webpack from resolving the path statically. - const wasmPath = './wasm/rulesengine.js'; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const wasm = require(wasmPath); + // Load the WASM loader via dynamic import() so bundlers code-split it + // into its own async chunk rather than inlining the (base64) binary + // into a consumer's entry bundle. A consumer that only uses the HTTP + // API and never reaches initialize() therefore doesn't ship the wasm. + // The loader is a CommonJS module (wasm-bindgen nodejs target), so + // unwrap the interop `default` before reading the export. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ns: any = await import('./wasm/rulesengine.js'); + const wasm = ns.RulesEngineJS ? ns : (ns.default ?? ns); this.wasmInstance = new wasm.RulesEngineJS(); this.initialized = true; } catch (error) {