/skills/fuz-stack/references/wasm-patterns
  • docs
  • skills
  • fuz-stack
    • Async Patterns
    • Code Generation
    • Common Utilities
    • CSS Patterns
    • Dependency Injection
    • Documentation System
    • File Organization
    • mdz — Strict Markdown Dialect
    • Approved npm Dependencies
    • Path References in Documentation
    • Approved Rust Dependencies
    • Rust Patterns for the Fuz Ecosystem
    • Rust Performance Patterns
    • Rust Spine & Consumer Servers
    • Svelte 5 Patterns
    • Task Patterns
    • Testing Patterns
    • TSDoc Comment Style Guide
    • Twin Implementations (TS ↔ Rust)
    • Type Utilities
    • WASM Patterns for the Fuz Ecosystem
    • Zod Schemas
  • grimoire
  • tools
  • hash

WASM Patterns for the Fuz Ecosystem

Applies to: blake3 (WASM hashing) and tsv (parser/formatter bindings — WASM, C-FFI, and N-API). The fuz workspace does not currently use WASM.

Publishing stance: npm gets both native (N-API) and WASM builds. The C-FFI cdylib additionally serves Deno FFI and Python.

Two Build Targets

ApproachToolConsumerUse case
wasm-bindgenwasm-packJS runtimesShip Rust to Deno/Node/browsers
Component modelcargo-componentWasmtime / pluginsSandboxed execution, composition

wasm-bindgen: generates glue code, handles memory management, produces .wasm + .js ready to import. Component model: capability-controlled execution — components declare imports/exports via WIT interfaces.

When to use which: npm publishing → wasm-bindgen; benchmarking across runtimes → both; plugin systems (speculative) → component model.

WIT Interface Design

package fuzdev:blake3@0.0.1; interface hashing { enum hash-error { invalid-key-length, } hash: func(data: list<u8>) -> list<u8>; keyed-hash: func(key: list<u8>, data: list<u8>) -> result<list<u8>, hash-error>; derive-key: func(context: string, key-material: list<u8>) -> list<u8>; resource hasher { constructor(); new-keyed: static func(key: list<u8>) -> result<hasher, hash-error>; new-derive-key: static func(context: string) -> hasher; update: func(data: list<u8>); finalize: func() -> list<u8>; finalize-and-reset: func() -> list<u8>; reset: func(); } } world blake3 { export hashing; }
  • Package naming <namespace>:<name>@<version> — use the fuzdev namespace.
  • WIT requires kebab-case; binding generators convert per language.
  • One-shot functions for stateless ops; resources for stateful streaming (hasher holds state across update/finalize).
  • result<T, E> with typed error enums (not strings); minimal enums — one variant per distinct failure mode.
  • Worlds declare capabilities — export hashing with no imports = pure computation, no ambient access.

Component Implementation (wit-bindgen)

From blake3_component:

use std::cell::RefCell; use exports::fuzdev::blake3::hashing; wit_bindgen::generate!({ path: "../../wit", world: "blake3", }); struct Component; export!(Component); impl hashing::Guest for Component { type Hasher = HasherResource; fn hash(data: Vec<u8>) -> Vec<u8> { blake3::hash(&data).as_bytes().to_vec() } fn keyed_hash(key: Vec<u8>, data: Vec<u8>) -> Result<Vec<u8>, hashing::HashError> { let key: [u8; 32] = key .try_into() .map_err(|_: Vec<u8>| hashing::HashError::InvalidKeyLength)?; Ok(blake3::keyed_hash(&key, &data).as_bytes().to_vec()) } // derive_key: same shape } struct HasherResource { inner: RefCell<blake3::Hasher>, } impl hashing::GuestHasher for HasherResource { fn new() -> Self { Self { inner: RefCell::new(blake3::Hasher::new()) } } fn new_keyed(key: Vec<u8>) -> Result<hashing::Hasher, hashing::HashError> { let key: [u8; 32] = key .try_into() .map_err(|_: Vec<u8>| hashing::HashError::InvalidKeyLength)?; Ok(hashing::Hasher::new(HasherResource { inner: RefCell::new(blake3::Hasher::new_keyed(&key)), })) } fn update(&self, data: Vec<u8>) { self.inner.borrow_mut().update(&data); } fn finalize(&self) -> Vec<u8> { self.inner.borrow().finalize().as_bytes().to_vec() } // new_derive_key / finalize_and_reset / reset: same RefCell shape }

Key patterns: wit_bindgen::generate! at compile time from WIT; unit struct + export!; RefCell for resource state (resources receive &self); static factories return hashing::Hasher wrapping the resource struct.

Cargo.toml for component crates

[lib] crate-type = ["cdylib"] [dependencies] blake3 = { workspace = true, features = ["wasm32_simd"] } wit-bindgen.workspace = true # Cannot use `lints.workspace = true`: wit-bindgen generates #[export_name] # and unsafe ABI stubs. Re-declare the ENTIRE workspace lint block (rust and # clippy tables — see rust-patterns.md §Lints; overriding only unsafe_code # silently drops the restriction-lint floor), changing only: [lints.rust] unsafe_code = "allow" # ... full re-declared [lints.rust] + [lints.clippy] block here ... # blake3_component additionally allows same_length_and_capacity + use_self # (false positives from generated code). [package.metadata.component] package = "fuzdev:blake3" [package.metadata.component.target] world = "blake3" path = "../../wit"

[package.metadata.component.target] is a sub-table — world and path go under target, not directly under component.

Build (requires cargo-component and the wasm32-wasip1 target; no wasm-opt pass for the component):

RUSTFLAGS='-C opt-level=3 -C target-feature=+simd128' \ cargo component build -p blake3_component --release

Host-Side Embedding (wasmtime)

Pin wasmtime/wasmtime-wasi at the same major (currently 45) and enable the component-model feature on wasmtime — the bindgen!/component APIs don't compile without it.

use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; wasmtime::component::bindgen!({ path: "../../wit", world: "blake3", }); struct HostState { ctx: WasiCtx, table: ResourceTable, } impl WasiView for HostState { fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.ctx, table: &mut self.table } } } // Setup let engine = wasmtime::Engine::new( wasmtime::Config::new().wasm_component_model(true) )?; let mut linker = wasmtime::component::Linker::new(&engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; let component = wasmtime::component::Component::from_file(&engine, wasm_path)?; let mut store = wasmtime::Store::new(&engine, HostState { ctx, table }); // Instantiate and call let instance = Blake3::instantiate(&mut store, &component, &linker)?; let hashing = instance.fuzdev_blake3_hashing(); let digest = hashing.call_hash(&mut store, data)?; // Resource lifecycle: host owns the handle, guest owns memory — // drop explicitly to free guest memory let hasher = hashing.hasher().call_constructor(&mut store)?; hashing.hasher().call_update(&mut store, hasher, chunk)?; let result = hashing.hasher().call_finalize(&mut store, hasher)?; hasher.resource_drop(&mut store)?;

wasm-bindgen Patterns

Crate architecture (blake3)

Shared core crate with thin wrappers — the SIMD split is genuinely two crates (contrast tsv, where the split is a feature axis within one crate):

CrateTypePurpose
blake3_wasm_corerlibShared wasm-bindgen exports + TS types
blake3_wasmcdylib + rlibSIMD build (enables blake3/wasm32_simd)
blake3_wasm_smallcdylib + rlibSize-optimized build (no SIMD)

Both wrappers contain only pub use blake3_wasm_core::*;.

Rust side

#[wasm_bindgen] pub fn hash(data: &[u8]) -> Vec<u8> { blake3::hash(data).as_bytes().to_vec() } #[wasm_bindgen] pub fn keyed_hash(key: &[u8], data: &[u8]) -> Result<Vec<u8>, JsError> { let key: [u8; 32] = key .try_into() .map_err(|_| JsError::new("key must be exactly 32 bytes"))?; Ok(blake3::keyed_hash(&key, data).as_bytes().to_vec()) } #[wasm_bindgen] pub struct Blake3Hasher { inner: blake3::Hasher } #[wasm_bindgen] impl Blake3Hasher { #[wasm_bindgen(constructor)] pub fn new() -> Self { Self { inner: blake3::Hasher::new() } } pub fn new_keyed(key: &[u8]) -> Result<Blake3Hasher, JsError> { /* ... */ } pub fn update(&mut self, data: &[u8]) { self.inner.update(data); } pub fn finalize(&self) -> Vec<u8> { self.inner.finalize().as_bytes().to_vec() } // new_derive_key / finalize_and_reset / reset }

Differences from the component model: &[u8] and &mut self (wasm-bindgen handles borrowing, no RefCell); JsError string messages, not typed enums; free() and Symbol.dispose generated automatically.

tsv wasm-bindgen patterns

Complex return types (ASTs) cross the boundary as a single JSON string, parsed with the engine's native JSON.parse via js-sys — building the JS object graph node-by-node with serde-wasm-bindgen was measurably slower and was dropped. Parsers are arena-based (rust-perf.md §Arena allocation): the binding runs inside with_ast_arena / with_doc_arena so per-call allocation amortizes to zero.

// lang_bindings! macro-generates four exports per language: // parse_<lang>, parse_<lang>_json, parse_internal_<lang>, format_<lang> // The extern type names the matching interface in the bundled tsv_ast.d.ts, // so wasm-pack declares the return as the typed AST (e.g. `SvelteRoot`). #[wasm_bindgen] pub fn parse_svelte(source: &str) -> Result<SvelteRoot, JsError> { let json = parse_svelte_json(source)?; let js_value = js_sys::JSON::parse(&json) .map_err(|_| err("internal error: AST serialized to invalid JSON"))?; Ok(js_value.unchecked_into::<SvelteRoot>()) } #[wasm_bindgen] pub fn parse_svelte_json(source: &str) -> Result<String, JsError> { with_ast_arena(|arena| { let ast = tsv_svelte::parse(source, arena).map_err(err)?; Ok(tsv_svelte::convert_ast_json_string(&ast, source)) }) }

parse_*_json returns the wire string directly for consumers that forward it without materializing a JS object. parse_internal_* benchmarks skip serialization via std::hint::black_box. Goal-aware exports (parse_typescript_json_with_goal, format_typescript_with_goal) sit outside the macro.

tsv_wasm runs wasm-opt with explicit feature flags — without them wasm-opt fails on Rust 2024's bulk-memory ops:

[package.metadata.wasm-pack.profile.release] wasm-opt = ['-O3', '--enable-bulk-memory', '--enable-nontrapping-float-to-int']

TypeScript entry points

Re-export from wasm-pack's pkg/ output and add stream functions:

import { Blake3Hasher, derive_key, hash, keyed_hash } from './pkg/deno/blake3_wasm.js'; export { Blake3Hasher, derive_key, hash, keyed_hash }; import { make_stream_functions } from './stream.ts'; export const { hash_stream, keyed_hash_stream, derive_key_stream } = make_stream_functions( Blake3Hasher, );

Node entry uses synchronous initialization (readFileSync + initSync). The generated packages bridge wasm-bindgen's camelCase to the ecosystem convention: initSync is re-exported as init_sync.

npm package structure

scripts/patch_npm_package.ts generates: index.js (Node auto-init), browser.js (async init(), exports guarded with _check()), stream.js, index.d.ts. Package exports map . → { types, node, default } conditions plus a ./package.json self-reference.

Streaming, disposal, consumer API

  • Stream helpers batch at 16 KB to reduce WASM boundary crossings: await hash_stream(file.stream()) etc., built via make_stream_functions(Blake3Hasher); the browser entry passes a _check guard against uninitialized WASM.
  • using hasher = new Blake3Hasher(); — wasm-bindgen generates Symbol.dispose, so free() runs at scope exit. Shared Blake3HasherInstance / Blake3HasherConstructor interfaces type the class across entries.
  • @fuzdev/fuz_util/hash_blake3.ts is the ecosystem consumer: export const blake3_ready = init(); (eager init — resolves immediately under sync init, awaited in browsers) and hash_blake3(data: Uint8Array | BufferSource | string): string returning 64-char hex (validated by the Blake3Hash Zod schema).

deno compile compatibility

wasm-bindgen's deno target loads WASM via fetch(), incompatible with deno compile. The build pipeline patches the generated JS to use Deno.readFileSync and creates a _bg.js stub for module resolution.

Multiple Binding Crates (tsv pattern)

A library targeting several runtimes keeps one binding crate per technology, all exporting identical macro-generated signatures (parse / parse_internal / format per language), so consumers choose by runtime:

CrateTechnologyTargetError type
tsv_wasmwasm-bindgenDeno, browsers, NodeResult<T, JsError>
tsv_napiN-APINode.js, Bun (native npm path)N-API errors
tsv_ffiC ABIDeno FFI, PythonJSON error objects

All three share the tsv_arena per-thread arenas. tsv_ffi and tsv_napi override unsafe_code = "allow" and re-declare the full workspace lint block (rust-patterns.md §Lints). tsv_ffi uses raw pointers with tsv_free(ptr, len) for memory management and wraps every entry point in panic::catch_unwind, rendering payloads as {"error": "panic: …"} — which requires the panic = "unwind" corpus profile to be effective (rust-patterns.md §Release Profile).

Package naming: _wasm suffix

WASM artifacts carry a _wasm suffix everywhere they could be confused with a native build; native artifacts stay bare. The suffix is part of the published identity — npm package, crate name, and the generated *_wasm_bg.wasm all agree.

ProjectWASM packagesNative
blake3@fuzdev/blake3_wasm (SIMD), @fuzdev/blake3_wasm_small (no SIMD)none
tsv@fuzdev/tsv_wasm (parse + format + tsv CLI), @fuzdev/tsv_format_wasm, @fuzdev/tsv_parse_wasmtsv CLI binary, tsv_ffi .so, tsv_napi .node
  • The three tsv WASM packages come from one crate. tsv_wasm has format/parse cargo features (default = both); the subset packages are --no-default-features --features format|parse builds. parse pulls the language crates' convert feature (the AST→JSON layer) + js-sys. The umbrella @fuzdev/tsv_wasm is the flagship (it ships the JS tsv CLI).
  • Native stays bare, and "tsv" is deliberately overloaded: the native CLI binary (tsv_cli crate), the C-FFI lib, and the JS CLI inside @fuzdev/tsv_wasm are all invoked as tsv — same tool, per-runtime delivery.
  • Drop redundant kind labels. Where artifacts are already grouped by kind, don't repeat (wasm) / (native) in the row name — the _wasm suffix (or its absence) carries it.

Two Packages, Not Two Profiles (blake3)

blake3 ships two npm packages from different crates. Both are size-optimized end-to-end (opt-level=s + wasm-opt -Os); the only differentiator is SIMD:

PackageCrateRUSTFLAGSwasm-optSize
@fuzdev/blake3_wasmblake3_wasm-C opt-level=s -C target-feature=+simd128-Os --enable-simd …~45 KB
@fuzdev/blake3_wasm_smallblake3_wasm_small-C opt-level=s-Os …~32 KB

SIMD build: ~2.6x faster at large inputs (Deno/Node), slower on Bun (WASM SIMD regression) — use the small build for Bun and bundle-size-sensitive contexts. A size regression test pins the byte counts. The wasmtime component is the exception — opt-level=3, since the host can absorb bytes for speed.

# blake3_wasm (SIMD) [package.metadata.wasm-pack.profile.release] wasm-opt = ["-Os", "--enable-simd", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-mutable-globals", "--enable-sign-ext", "--strip-producers"] [dependencies] blake3_wasm_core = { path = "../blake3_wasm_core", features = ["simd"] }

blake3_wasm_small is the same minus --enable-simd and without the simd feature. Rust 2024 enables bulk memory for wasm32-unknown-unknown, so wasm-opt needs --enable-bulk-memory (and friends) or it fails. --strip-producers removes compiler metadata.

Build commands

RUSTFLAGS='-C opt-level=s -C target-feature=+simd128' \ wasm-pack build crates/blake3_wasm --scope fuzdev --target deno --release --out-dir pkg/deno RUSTFLAGS='-C opt-level=s' \ wasm-pack build crates/blake3_wasm_small --scope fuzdev --target deno --release --out-dir pkg/deno

Why RUSTFLAGS: wasm-pack doesn't support --profile (conflicts with --release), so RUSTFLAGS overrides at the compiler level. The base [profile.release] keeps opt-level = "s" plus the canonical lto/codegen-units/panic/strip block.

The build pipeline runs the two packages in parallel; deno and web targets run sequentially within each (shared cargo intermediate artifacts).

Testing

blake3 keeps zero Rust unit tests by design: correctness is asserted in TypeScript (WASM vs native test vectors) and via a Wasmtime compare binary for the component; cargo test --workspace serves as a compile gate. tsv's binding tests run per runtime (Deno, N-API, npm) plus in-crate FFI/N-API round-trip tests — see rust-patterns.md §Testing.

Cross-References

ResourceLink
Blake3 WASM bindingsfuzdev/blake3
Component model spec — WITWebAssembly/component-model WIT
Component model spec — ExplainerWebAssembly/component-model Explainer
Rust patterns./rust-patterns
Rust performance (arenas)./rust-perf