Skip to content

Daksh14/wasm_zero

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wasm_zero

A lightweight WASM FFI binding generator and rkyv serialization wrapper between TypeScript and Rust. Built for no_std wasm targets — no wasm-bindgen, no JS glue runtime, no allocator assumptions beyond your own.

🌐 Live demo: https://daksh14.github.io/wasm_zero/

Annotate a Rust function with #[wasm_zero], and wasm_zero gives you:

  • an exported FFI shim that returns the function's result as an rkyv zero-copy archive, and
  • generated, self-contained TypeScript/JavaScript bindings that call the shim and read each field straight out of wasm memory — no runtime library on the read path, no extra serialization layer.
#[wasm_zero]
pub fn get_adult_person() -> Person { /* ... */ }
import { initWasmZero } from "./pkg/bindings.js";

const wasmzero = await initWasmZero("app.wasm");
const person = wasmzero.get_adult_person(); // -> { name, age, email, scores }

Why

Motivation

This library exists to enable and skyrocket WASM application development that touches the frontend — and to make the web a faster and more secure place. Rust compiled to WASM gives you memory safety and near-native speed; wasm_zero removes the friction of getting that power to the browser. You annotate a Rust function, and wasm_zero emits a JavaScript/TypeScript shim you can drop straight into a bare .html file — no bundler, no glue runtime, no std assumption. The same emitted wasm can be run on the server with wasmtime or a self hosted VM. To allow wasi targets set --target wasm32-wasi as target. wasm-bindgen is excellent but pulls in a JS glue.

For small no_std wasm modules that just need to hand structured data to a JavaScript host, that's a lot of machinery. wasm_zero takes a different tack:

  • The Rust side serializes return values with rkyv into a flat byte buffer.
  • The JS side reads that buffer field-by-field straight from wasm memory, using readers generated from your Rust types — no hand-maintained schema files and no runtime decode library. Strings still use TextDecoder which has a runtime cost along with branching for SSO.
  • The only ABI surface is a handful of integer-in/integer-out functions plus linear memory. In the future we want to provide a way to provide a validated abi at compile time for custom ABI needs

At Dusk Network: exu

At Dusk Network we engineered a glue layer that runs on no_std, is secure and sandboxed, and executes efficiently in the browser without blocking the main thread by running inside Web Workers — alongside wasm_zero.

That runtime is exu. It works in the web browser or the Node.js runtime to run your WASM with:

  • Web Workers in the browser and actual OS-level threads in Node.js, so invocations run off the main thread, non-blockingly.
  • Sandboxing — your WASM runs isolated. After a function invocation exu can delete the WASM memory and drop the worker entirely, so nothing leaks between calls.

We maintain a fork of exu (vendored in exu/) that extends upstream with a rayon thread pool running over shared wasm memory — no wasm-bindgen required. The fork keeps exu's sandboxing model intact: the sandbox boundary is the wasm instance + memory, not the worker. Workers are pooled and reused across tasks, while a fresh WebAssembly.Memory is minted per task so nothing leaks between invocations — yet within a task, the rayon pool's worker threads all share that one memory for zero-copy parallelism (SharedArrayBuffer + atomics).

Example: sandboxing + a shared-memory rayon pool

Each module.task(...) runs in its own sandbox over fresh memory (terminated when the task resolves). Inside the task, initThreadPool(threads) stands up a rayon pool whose workers share that sandbox's memory, so a parallel compute call fans out across cores without copying:

import { Module } from "./exu/src/mod.js";

const module = new Module(new URL("wasm_zero_rayon.wasm", import.meta.url));
module.defaultImports = new URL("imports.js", import.meta.url);

// Run a task: fresh sandbox + memory, dropped when this resolves (sandboxing).
const result = await module.task(async (exports, { initThreadPool }) => {
  // Spin up a rayon pool over this sandbox's shared memory.
  const threads = await initThreadPool(navigator.hardwareConcurrency);
  const primes = await exports.__wasm_zero_parallel_count_primes(1_000_000);
  return { primes, threads };
})();

console.log(result); // { primes: 78498, threads: <cores> }

The next module.task(...) gets a brand-new memory (sandboxed), while the underlying workers are recycled from the pool. The wasm_zero_rayon crate provides the compute functions and the thread-pool bootstrap; see exu/tests/rayon_deno_test.mjs for the end-to-end run (verified under Deno, ~3.8× on 10 threads) and exu/tests/mandelbrot_deno_test.mjs for a parallel Mandelbrot render that reads the RGBA buffer straight out of shared memory.

Note: shared memory needs cross-origin isolation — wasm_zero_serve stamps COOP/COEP on every response so SharedArrayBuffer is available in the browser.

How it works

#[wasm_zero] fn foo() -> T
        │
        ├── wasm_zero_macro (proc macro, compile time)
        │     emits  __wasm_zero_foo(out_ptr: u32) -> u32
        │     which rkyv-serializes T and writes [len: u32][bytes] at out_ptr
        │
        └── wasm_zero_build (build.rs, before compile)
              scans the source and emits pkg/bindings.{ts,js}:
                • per-struct readers (decode_Person reads each field)
                • FFI client         (initWasmZero / wasmzero.foo())

A proc macro can only return tokens to the compiler — it can't write files. A build.rs runs before the compiler and can. So the two responsibilities are split: the macro transforms code, the build helper emits the binding artifacts. This is the same division prost/prost-build and uniffi use.

The FFI / memory protocol

For each #[wasm_zero] fn foo(args...) -> T:

  1. The module exports a shim plus malloc / free (from wasm_zero::mem). Nullary functions export __wasm_zero_foo(out_ptr) -> u32; functions with arguments export __wasm_zero_foo(in_ptr, out_ptr) -> u32.
  2. Arguments that are scalar primitives (i8u64, f32, f64, bool) are passed directly as wasm function parameters — no encoding, no input buffer (the fast path). If any argument is non-scalar (String, a struct, Vec, …), all args are instead rkyv-encoded (r.encode) into an input buffer ([len][bytes]) and in_ptr is passed.
  3. If the return type is a scalar primitive or (), the shim returns it directly as the wasm function's return value — no output buffer, no rkyv, no error code (it can't fail). Otherwise it writes [len: u32 little-endian][rkyv archive bytes] at out_ptr and returns an ErrorCode (Ok == 0).
  4. For buffer returns, JS reads len and decodes straight from wasm memory using a generated per-struct reader — each field read at its archived offset (scalars via DataView, numeric vecs as zero-copy typed-array views, strings transcoded on demand). No runtime library is involved on the read path. Scalar/unit returns are just the call's return value.

Both directions use the same [len][bytes] framing (the archive starts at a 16-byte-aligned offset so typed-array views are correctly aligned).

rkyv is configured for the standard v0.8 format: little-endian, aligned primitives, 32-bit relative pointers, root at the end of the buffer.

Workspace layout

Crate Role
wasm_zero no_std runtime library: the #[wasm_zero] re-export, ErrorCode, and mem (malloc/free + buffer helpers).
wasm_zero_macro The #[wasm_zero] attribute proc macro that emits the FFI shim.
wasm_zero_build build.rs helper that generates the self-contained TS/JS bindings (bindings.ts + bindings.js).
wasm_zero_serve Tiny axum static-file server for running the demo pages.
wasm_zero_test_nostd no_std demo: rkyv types + #[wasm_zero] functions + a browser page.
wasm_zero_test A wasm-bindgen/std comparison crate.
wasm_zero_rayon Shared-memory rayon compute fns (parallel_count_primes, …) + the thread-pool bootstrap, driven by the exu fork.
wasm_zero_rayon_demo Parallel Mandelbrot render over shared wasm memory (returns an RGBA pointer JS reads zero-copy).

There's also a standalone benchmark/ workspace comparing wasm-bindgen and wasm_zero head-to-head — call overhead, data transfer, and bundle size (benchmark/sizes.sh: wasm_zero ships ~2.2× smaller gzipped — 5.8 KB vs 12.8 KB — with no glue runtime and no read-path dependency at all).

Usage

1. Add the dependencies

[dependencies]
wasm_zero = "0.1"
rkyv = { version = "0.8", default-features = false, features = ["alloc", "pointer_width_32"] }

[build-dependencies]
wasm_zero_build = "0.1"

2. Annotate your types and functions

#![no_std]
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;

use rkyv::{Archive, Deserialize, Serialize};
use wasm_zero::wasm_zero;

#[derive(Archive, Serialize, Deserialize)]
pub struct Person {
    pub name: String,
    pub age: u32,
    pub email: Option<String>,
    pub scores: Vec<u32>,
}

#[wasm_zero]
pub fn get_adult_person() -> Person {
    Person { /* ... */ }
}

3. Generate the bindings from build.rs

fn main() {
    // Writes pkg/bindings.ts and pkg/bindings.js
    wasm_zero_build::generate("src/lib.rs", "pkg");
}

This produces a self-contained FFI client — a TypeScript interface plus a zero-copy reader per struct, with no runtime dependency:

export interface Person {
  name: string;
  age: number;
  email: string | null;
  scores: Uint32Array; // zero-copy view over wasm memory
}

// reads each field straight from wasm memory at its archived offset
function decode_Person(dv, u8, p) { /* ... */ }

export async function initWasmZero(wasmUrl: string | URL, imports?: WebAssembly.Imports): Promise<WasmZero>;

Two files are emitted from one model:

  • bindings.ts — plain TypeScript interfaces + typed client.
  • bindings.js — the same module with types stripped, importable directly in a browser (no bundler, no CDN).

rkyv-js is imported only if a function takes a non-scalar argument (String/struct/…) — it's used to r.encode the argument into the input buffer (hand-rolling the rkyv writer is out of scope). The read path never needs it.

4. Build the wasm and call it

cargo build --target wasm32-unknown-unknown -p your_crate
<script type="module">
  import { initWasmZero } from './pkg/bindings.js';
  const wasmzero = await initWasmZero('your_crate.wasm');
  console.log(wasmzero.get_adult_person());
</script>
<!-- Only if a function takes a non-scalar argument, the bindings import
     rkyv-js; add an import map then:
     <script type="importmap">
       { "imports": { "rkyv-js": "https://esm.sh/gh/cometkim/rkyv-js" } }
     </script> -->

Running the demo

# Build the no_std demo wasm
cargo build --target wasm32-unknown-unknown -p wasm_zero_test_nostd

# Serve the workspace (defaults to http://127.0.0.1:8000)
cargo run -p wasm_zero_serve

Then open http://127.0.0.1:8000/crates/wasm_zero_test_nostd/index.html. The page imports the generated pkg/bindings.js, calls greet() and get_adult_person(), and renders the decoded values.

no_std notes

The demo crate shows the expected setup for a no_std cdylib:

  • a #[global_allocator] (the demo uses dlmalloc),
  • a #[panic_handler] (core::arch::wasm32::unreachable()),
  • panic = "abort" (via .cargo/config.toml) so no eh_personality is needed.

Optimizing the wasm

wasm_zero already minimizes per-call work: scalar args/returns are bare wasm calls (no buffer), the shim serializes directly into the output buffer (no intermediate allocation or copy), and numeric vecs return zero-copy views.

For the smallest, fastest module, tune the consuming crate's release profile:

[profile.release]
opt-level = "z"    # smallest; use 3 for fastest
lto = "fat"        # cross-crate inlining
codegen-units = 1  # max optimization
strip = true       # drop symbols

Then run wasm-opt on the output (wasm-opt -O3 app.wasm -o app.wasm; needs a recent Binaryen). Building with RUSTFLAGS="-C target-feature=+bulk-memory" enables memory.copy/fill for faster byte copies where your targets support it.

Supported types

wasm_zero reads these Rust types from the archive into TypeScript:

Rust TypeScript
u8i32, f32, f64, usize/isize number
u64, i64 bigint
bool boolean
char, String string
Vec<T> of a numeric primitive Uint32Array / Float64Array / … (zero-copy view)
Vec<T> (other) T[]
Option<T> T | null
Box<T> / Rc<T> / Arc<T> T
#[derive(Archive)] struct interface

Enums, maps, tuples, and [T; N] arrays aren't read yet (the build fails with a clear message). The reader is generated from the rkyv archived layout; for the non-scalar argument path, encoding uses rkyv-js.

Argument handling

wasm_zero picks the cheapest way to pass arguments based on their types:

Arguments How they cross Cost
none nullary shim
all scalar primitives (i8u64, f32, f64, bool) passed directly as wasm params none — like a bare call
any non-scalar (String, struct, Vec, …) rkyv-encoded (r.encode) into the input buffer one encode + copy

(i64/u64 args are passed as JS BigInt.)

Return handling

Return values are read straight from wasm memory by a generated per-struct reader (decode_<Struct>) — each field at its archived offset, no runtime library:

Return type How it crosses Result
scalar primitive or () returned directly as the wasm function's value — a bare call, no buffer (matches wasm_bindgen) number / bigint / boolean / void
Vec<T> of a numeric primitive zero-copy typed-array view (Uint32Array, Float64Array, …) over the archived elements a view that aliases wasm memory
struct / String / Option / Vec<T> / nested fields read directly at their offsets (DataView/views; strings transcoded) a plain object; numeric-vec fields are views

Notes:

  • Views alias the shared scratch buffer, so a returned struct's numeric-vec field (or a top-level numeric-vec return) is only valid until the next call on that instance (or a memory.grow). .slice() / copy it to keep it. Scalar and string fields are owned (copied) and safe to keep.
  • The rkyv layout for numeric vecs is native little-endian, so the view needs no copy and no per-element decode. Strings are always materialized (UTF-8 → UTF-16) when read.

This is why, in the benchmark, wasm_zero matches or beats wasm_bindgen on scalar calls and numeric-array returns; for full struct materialization with strings, serde-wasm-bindgen's single-pass build is still a touch faster, while wasm_zero wins when you read only some fields.

Limitations

  • Arguments must be owned rkyv types (e.g. i32, String, a #[derive(Archive)] struct) — borrowed parameters like &str aren't decodable from the input buffer. Non-scalar arguments require rkyv-js (for encoding).
  • The generated readers cover scalars, String, Option, Vec, Box/Rc/Arc, and #[derive(Archive)] structs. Enums, maps, and tuples aren't read yet (the build fails with a clear message).
  • Only the standard rkyv v0.8 format is supported (little-endian, aligned, 32-bit pointers).

License

See individual crate headers; portions are MPL-2.0 (Dusk Network).

About

A lightweight WASM FFI binding generator and rkyv serialization wrapper between typscript and rust supports no_std wasm platforms

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors