walrus bindings for JavaScript

See the shape of your wasm

Read, edit and build WebAssembly modules from JavaScript. Every function, global, memory, import and export is a live handle — read through to the module, write straight back, then emitWasm().

$pnpm add @napi-rs/wasm-tools -D
module.wasm
8 nodes1 edit
type (i32,i32)→i32import env.logfn runglobal countermemoryexport "main"export "counter"export "memory"
01 — Three verbs

Inspect. Edit. Build.

One module handle, three things to do with it. The same graph you read is the graph you mutate and the graph you emit.

Inspect

Walk every collection through live handles — exports, imports, globals, memory, types. Iterate, or look items up by name.

inspect.ts
import { WasmModule } from '@napi-rs/wasm-tools'

const mod = WasmModule.fromBuffer(bytes)

// Every collection is a live handle: .length, .items(), lookups.
for (const exp of mod.exports.items()) {
  if (exp.kind === 'Global') {
    console.log(exp.name, exp.global()!.ty.type)
  }
}

mod.exports.byName('run')       // WasmExport | null
mod.imports.find('env', 'log')  // WasmImport | null
Edit

Assign to a handle and the write lands in the owning module. Rename an export, flip a global, grow memory — then emit.

edit.ts
// Mutations write straight back to the owning module.
mod.name = 'patched-module'
mod.exports.byName('run')!.name = 'main'              // rename an export
mod.exports.byName('counter')!.global()!.mutable = true
mod.mainMemory!.initial = 4n                          // grow memory (bigint)

const bytes = mod.emitWasm(false)
Build

Synthesize functions from an instruction-descriptor tree, add locals, wire exports, and emit a module that really runs.

build.ts
import { I32, WasmModule } from '@napi-rs/wasm-tools'

// Locals are module-wide; make them, then bind as params.
const a = mod.locals.add(I32)
const b = mod.locals.add(I32)

const idx = mod.buildFunction(
  [I32, I32], [I32], [a.index, b.index],
  [
    { type: 'LocalGet', local: a.index },
    { type: 'LocalGet', local: b.index },
    { type: 'Binop', op: 'I32Add' },
  ],
)
02 — Live handles

Edit an export, re-emit, it persists

There is no separate mutation API and no rebuild step. A handle is a window onto the module — reads read through, writes write back. Round-trip through the bytes to prove it.

1

parse — fromBuffer reads the module into a live graph.

2

edit — Assign to a handle — the write lands in the module.

3

re-emit — emitWasm serializes the mutated graph to bytes.

4

prove — Re-parse the bytes: every edit is baked in.

round-trip.ts
import { readFile } from 'node:fs/promises'
import { WasmModule } from '@napi-rs/wasm-tools'

const mod = WasmModule.fromBuffer(await readFile('./module.wasm'))

mod.exports.byName('run')!.name = 'main'   // rename an export
mod.mainMemory!.initial = 4n               // grow initial memory

// Re-parse the emitted bytes to prove every edit baked in:
const out = WasmModule.fromBuffer(mod.emitWasm(false))
out.exports.byName('run')         // null
out.exports.byName('main')!.kind  // 'Function'
out.mainMemory!.initial           // 4n
build-add.ts
import { I32, WasmModule } from '@napi-rs/wasm-tools'

// Start from the 8-byte empty module (`\0asm` + version 1).
const mod = WasmModule.fromBuffer(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))

// Locals are module-wide; make them, then bind as params.
const a = mod.locals.add(I32)
const b = mod.locals.add(I32)

const idx = mod.buildFunction(
  [I32, I32], [I32], [a.index, b.index],
  [
    { type: 'LocalGet', local: a.index },
    { type: 'LocalGet', local: b.index },
    { type: 'Binop', op: 'I32Add' },
  ],
)
mod.exports.addFunction('add', mod.functions.getByIndex(idx)!)

const bytes = mod.emitWasm(false)
// new Uint8Array(bytes) narrows the buffer type so instantiate picks its BufferSource
// overload (emitWasm's Uint8Array<ArrayBufferLike> otherwise resolves to the Module one).
const { instance } = await WebAssembly.instantiate(new Uint8Array(bytes))
const add = instance.exports.add as (a: number, b: number) => number
console.log(add(2, 3)) // 5

// The body round-trips back to descriptors:
const fn = WasmModule.fromBuffer(bytes).exports.byName('add')!.func()!
fn.instructions()
// [ { type: 'LocalGet', local: 0 }, … { type: 'Binop', op: 'I32Add' } ]
03 — Instruction builder

Describe add(a, b), run it, get 5

buildFunction takes params, results, the locals that are params, and an instruction-descriptor tree. Export it, emit, and WebAssembly.instantiate runs the real thing. The body round-trips back to the same descriptors.

instance.exports.add(2, 3)= 5
04 — API map

27 classes, 17 value types

The full walrus module graph, exposed. Collections hand out handles; handles read and write through to the module; value types come ready-made so you import I32 instead of spelling it out.

Core

3

Entry points — configure a parse, hold a module, build const exprs.

ModuleConfigWasmModuleConstExpr

Collections

11

Live arena sets on a module: .length, .items(), lookups and add* factories.

WasmFunctionsWasmGlobalsWasmMemoriesWasmTablesWasmTypesWasmImportsWasmExportsWasmDataSegmentsWasmElementsWasmTagsWasmLocals

Handles

11

One item each — read a property through to the module, write it back.

WasmFunctionWasmGlobalWasmMemoryWasmTableWasmTypeWasmImportWasmExportWasmDataWasmElementWasmTagWasmLocal

Section façades

2

Single-section wrappers with their own API — not .length / .items() collections.

WasmProducersWasmCustomSections

Value types

17

Numeric types and the nullable reference types, exported as constants — import { I32 } instead of { type: 'I32' }.

I32I64F32F64V128FUNCREFEXTERNREFANYREFEQREFI31REFSTRUCTREFARRAYREFNULLREFNULLFUNCREFNULLEXTERNREFEXNREFNULLEXNREF

PLAYGROUND

Walk your module in the browser

Paste WAT or drop a .wasm and watch the graph build itself — parse, inspect, edit and re-emit, all client-side.

@napi-rs/wasm-tools

walrus bindings for JavaScript — read, edit and build WebAssembly modules through live handles.

Built with @napi-rs · powered by walrus · MIT licensed