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 ] 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();