@alexbruf/wasmkernel
v0.2.1
Published
Drop-in replacement for @napi-rs/wasm-runtime backed by wasmkernel (WAMR cooperatively scheduled). Runs napi-rs addons in Node, browsers, and workers from a single package.
Maintainers
Readme
@alexbruf/wasmkernel
Drop-in replacement for @napi-rs/wasm-runtime backed by wasmkernel — a cooperatively-scheduled WAMR-based N-API runtime for JavaScript.
Why
@napi-rs/wasm-runtime uses emnapi to run napi-rs addons in the browser / Node / Workers. It works, but relies on spawning Web Workers for threading, doesn't offer cooperative scheduling, and has a number of papercuts under sustained load.
@alexbruf/wasmkernel is a drop-in replacement that runs the same addons through our own WAMR-based interpreter with cooperative scheduling, a wall-clock watchdog, and first-class N-API compliance against Node's reference suite.
It exposes the same three functions napi-rs's published .wasi.cjs loaders import, so you can swap it in by redirecting the module — no changes to the published addon.
Installation
npm install @alexbruf/wasmkernel
# or
bun add @alexbruf/wasmkernelFor browser/worker usage, also install the WASI shim:
npm install @bjorn3/browser_wasi_shimUsage
As a drop-in replacement for @napi-rs/wasm-runtime
Most napi-rs addons ship with a *.wasi.cjs loader that require('@napi-rs/wasm-runtime'). You have two options:
Module alias — tell your bundler/resolver that
@napi-rs/wasm-runtimeresolves to@alexbruf/wasmkernel. In Node you can override via a customModule._resolveFilenamehook, in esbuild/webpack viaalias, in Vite viaresolve.alias.Direct usage — call
instantiateNapiModuleyourself with the guest wasm bytes.
Direct (Node)
import { loadNapiRs } from "@alexbruf/wasmkernel/node";
const { exports: oxc } = await loadNapiRs("./parser.wasm32-wasi.wasm");
const r = oxc.parseSync("f.js", "const x = 1 + 2");Direct (Browser / Workers)
import { instantiateNapiModule } from "@alexbruf/wasmkernel/browser";
import { WASI } from "@bjorn3/browser_wasi_shim";
const guestBytes = new Uint8Array(await (await fetch("./addon.wasm")).arrayBuffer());
const wasi = new WASI([], [], []);
const { napiModule } = await instantiateNapiModule(guestBytes, { wasi });
const addon = napiModule.exports;Examples
Three runnable examples live under examples/ in this repo:
examples/node— CLI that loads the publishedoxc-parserwasm and parses a JS fileexamples/browser— HTML page that parses code typed into a textareaexamples/web-worker— moves the addon onto a Web Worker so the main thread stays responsiveexamples/cloudflare-worker— runs the addon inside a Cloudflare Worker (workerd) and returns a parsed AST
Exports
Same shape as @napi-rs/wasm-runtime:
instantiateNapiModuleSync(guestBytes, options)— synchronous on Node, throws on browser/workers. Returns{ instance, module, napiModule }. Used by published.wasi.cjsloaders.instantiateNapiModule(guestBytes, options)— async version, works everywhere.getDefaultContext()— returns an empty context (vestigial emnapi API).createOnMessage(fsApi)— returns a no-op message handler. Wasmkernel doesn't use napi-rs's in-worker fs proxy.
Subpath entries
@alexbruf/wasmkernel/node— Node entry, ESM + CJS@alexbruf/wasmkernel/browser— Browser entry (async only)@alexbruf/wasmkernel/worker— Worker entry (re-exports the browser entry)
The package also resolves under conditional exports (node, browser, worker/workerd) when imported as the bare specifier @alexbruf/wasmkernel.
Options
wasi(required) — a WASI implementation. On Node, passnew WASI({ version: "preview1", ... })fromnode:wasi. On browsers, pass a@bjorn3/browser_wasi_shiminstance or equivalent.minInitialPages(default: 4000) — minimum initial memory pages for the guest. Matches emnapi's default of a 256 MB shared memory; needed so Rust's allocator in napi-rs addons places buffers where the addon was validated. See Paged memory below for why you might need to bump this.beforeInit({ instance })— called beforenapi_register_module_v1runs. Use this to call__napi_register__*exports manually. If omitted, we auto-call every one we find.wasiBridges— extra WASI import handlers beyond the built-inrandom_get. Ignored underslotCyclingPages(the default-bridge set already handles what NAPI needs, without the pointer-math the general passthrough relies on).memoryBackend— aMemoryBackend(see@alexbruf/wasmkernel/memory-backend//backends/sqlite-do//backends/sqlite-node). Required for paging. Missing pages zero-fill.hotWindowPages— when set withmemoryBackend, enables in-place-swap paging. The JS page cache tracks which logical pages are resident; evictions flush to the backend. On Node this reduces RSS viaMADV_FREE; on CF workerd it's advisory only (V8 doesn't decommit).slotCyclingPages— when set withmemoryBackend, enables slot-cycling paging. The kernel'smemory_datais physically allocated at onlyN * 64K; slots rotate across logical pages. Hard RSS cap regardless of platform. Use this on CF Workers. Auto-liftssharedMemMaxPagesdefault to 65536 (4 GB logical).sharedMemMaxPages— cap on the declared max for imported shared memories. Default 8192 (512 MB) for compatibility with the CF 128 MB isolate cap; auto-lifts to 65536 when slot-cycling is on.asyncWorkPoolSize,onCreateWorker,reuseWorker,context,overwriteImports— accepted for compatibility with@napi-rs/wasm-runtime, currently ignored.
The async entry returns { instance, module, napiModule, kernel, napiRuntime, pageCache }. pageCache is non-null when paging is active.
Paged guest memory
Heavy napi-rs addons (oxc, rolldown) declare 64-256 MB of initial wasm memory. On Cloudflare Workers with a 128 MB isolate cap that's too much; on browsers it wastes memory the tab never needs.
Slot-cycling caps the kernel's linear-memory allocation at slotCyclingPages * 64K regardless of how much logical memory the guest addresses. Cold pages spill to the memoryBackend. Measured on a 512 MB pure-WASI workload: memory_data drops from 512 MB to 1 MB (slot-cycle hot=16). Logical ceiling is 4 GB (full wasm32 address space).
import { instantiateNapiModule } from "@alexbruf/wasmkernel/worker";
import { createSqliteBackend } from "@alexbruf/wasmkernel/backends/sqlite-do";
const backend = createSqliteBackend(ctx.storage.sql, {
// Fresh UUID per DO instance invalidates stored pages from a prior
// kernel/guest. Use a stable string if you want cross-restart
// persistence.
version: crypto.randomUUID(),
});
const { napiModule, pageCache, kernel } = await instantiateNapiModule(
guestBytes,
{
wasi,
memoryBackend: backend,
hotWindowPages: 256,
slotCyclingPages: 256, // opt in to the physical RSS cap
minInitialPages: 1024, // > 980 to avoid an oxc-internal edge case
},
);
// Runtime-adjustable — shrink under RSS pressure, grow when quiet.
pageCache.resize(128);
kernel.kernel_hot_window_pages(); // -> 128
kernel.kernel_hot_window_max_pages(); // -> 256 (allocation ceiling)Verified end-to-end in miniflare/workerd with oxc-parser and rolldown (BindingBundler.generate() producing correct ESM output from multi-module fixtures). See examples/cf-paged-do/ for a full Cloudflare Worker + Durable Object example with /oxc, /rolldown, /status, /resize endpoints.
Cross-DO sql capture (critical)
createSqliteBackend(sql, ...) closes over the sql reference. In workerd, multiple DO instances share a single V8 isolate, so a module-level singleton holding a backend binds to the first DO's sql. Subsequent DOs in the same isolate trip "Cannot perform I/O on behalf of a different Durable Object" — and because that error fires inside a host bridge, the failure cascades into unpredictable Rust panics (we've seen Condvar::wait, "uninitialized element", "Ctor is not a constructor" — all the same root cause).
Keep the backend and anything holding a reference to it (the pageCache, napiModule.exports, etc.) on a per-DO-instance field, never a module-level variable. See the header comment in backends/sqlite_do.js for the safe vs unsafe patterns.
Diagnostics
When a host bridge throws, wasmkernel now:
- Logs
[wasmkernel] bridge err: ...to console. - Copies the message into the kernel's error buffer via
kernel_set_bridge_error. - Sets a WAMR exception on the guest instance.
- When the interpreter sees the exception,
kernel_callcaptures the call stack and returns-3. - The thrown JS
Errorincludes both the bridge message and the guest call stack.
This turns what was a silent return 0n + downstream corruption into a clear error message. You can read the raw buffer yourself via kernel.kernel_last_error_ptr() — useful under workerd/miniflare where stderr isn't routed anywhere visible.
Differences from @napi-rs/wasm-runtime
| Aspect | @napi-rs/wasm-runtime | @alexbruf/wasmkernel |
|---|---|---|
| Engine | emnapi on host JS engine | WAMR compiled to wasm32-wasi |
| Threading | Web Workers | Cooperative single-process |
| Scheduling | Host engine | Fuel-based, wall-clock watchdog |
| Sync instantiate | Yes (Node) | Yes (Node), no (browser) |
| Async work pool | Real worker threads | Cooperative, single thread |
| N-API coverage | emnapi's subset | 100% of Node's test/js-native-api functions |
Limitations
- Async work runs on the main thread. Addons that rely on true parallelism (e.g.
@napi-rs/imagescaling on multiple cores) won't see a speedup here. They'll still work — just serialized. - Browser path requires a WASI shim.
@bjorn3/browser_wasi_shimis the reference. - This is pre-1.0 software. The N-API surface is complete and tested against real packages (argon2, oxc-parser, bcrypt, @tailwindcss/oxide), but the finalizer ordering and resource-accounting edges are still being hardened. See the root
CLAUDE.mdfor known issues.
License
MIT.
