Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 25 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,23 @@ touches the frontend — and to make the web a faster and more secure place. Rus
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 shim works on a server or inside a [Spin](https://www.fermyon.com/spin)
Fermyon edge container, so one model spans the browser, the edge, and the
backend. The less machinery between Rust and the host, the smaller, faster, and
easier to audit the result — which is the whole point.
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. This library is meant for
rust to js communication and not the other way.

`wasm-bindgen` is excellent but pulls in a JS glue runtime and assumes `std`.
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.
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.
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

Expand All @@ -69,6 +70,10 @@ web browser or the Node.js runtime to run your WASM with:
delete the WASM memory and drop the worker entirely, so nothing leaks between
calls.

Note there are caveats with running thread pool like rayon in wasm and rust
version doesn't map to directly to web. At least until the shared everything
proposal. [Read more about this in wasm-bindgen documentation](https://wasm-bindgen.github.io/wasm-bindgen/examples/raytrace.html?highlight=threading#caveats)

We maintain a **fork of exu** (vendored in [`exu/`](exu)) that extends upstream
with a [**rayon**](https://github.com/rayon-rs/rayon) thread pool running over
*shared* wasm memory — no `wasm-bindgen` required. The fork keeps exu's
Expand Down Expand Up @@ -151,8 +156,9 @@ For each `#[wasm_zero] fn foo(args...) -> T`:
2. Arguments that are scalar primitives (`i8`…`u64`, `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.
`Vec`, …), all args are instead rkyv-archived **directly into** the input
buffer (`[len][bytes]`) — rkyv-js writes through a fixed `RkyvWriter` bound
to that region of wasm memory, so nothing is copied — 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
Expand Down Expand Up @@ -265,8 +271,9 @@ Two files are emitted from one model:
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.
(`String`/struct/…) — and then only its encoder-only `rkyv-js/encode` entry,
which archives the argument straight into the input buffer (hand-rolling the
rkyv *writer* is out of scope). The read path never needs it.

### 4. Call it

Expand All @@ -277,9 +284,10 @@ Two files are emitted from one model:
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:
rkyv-js/encode (needs rkyv-js >= 0.2.0 for the external-buffer
RkyvWriter); add an import map then:
<script type="importmap">
{ "imports": { "rkyv-js": "https://esm.sh/gh/cometkim/rkyv-js" } }
{ "imports": { "rkyv-js/": "https://esm.sh/rkyv-js@0.3.0/" } }
</script> -->
```

Expand Down Expand Up @@ -360,7 +368,7 @@ wasm_zero picks the cheapest way to pass arguments based on their types:
|-----------|----------------|------|
| none | nullary shim | — |
| all scalar primitives (`i8`…`u64`, `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 |
| any non-scalar (`String`, struct, `Vec`, …) | rkyv-archived **in place** into the input buffer via a fixed `RkyvWriter` | one archive, no copy |

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

Expand Down Expand Up @@ -395,7 +403,8 @@ a touch faster, while wasm_zero wins when you read only some fields.

- 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).
buffer. Non-scalar arguments require `rkyv-js` >= 0.2.0 (its encoder-only
`rkyv-js/encode` entry, for the external-buffer `RkyvWriter`).
- 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).
Expand Down
7 changes: 5 additions & 2 deletions benchmark/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ cargo run -p wasm_zero_serve # serve the repo root (from the parent workspace)
```

Open <http://127.0.0.1:8000/benchmark/web/index.html> and click **Run
benchmarks**. (rkyv-js is loaded from GitHub via esm.sh, so the page needs
benchmarks**. (rkyv-js resolves to its npm package via esm.sh, so the page needs
network access the first time.)

## Bundle size
Expand All @@ -71,7 +71,8 @@ Caveats (also printed by the script):
- wasm_bindgen ships the `.wasm` plus a **per-module** JS glue file.
- wasm_zero's `bindings.js` is **self-contained**: it decodes straight from wasm
memory with no runtime dependency. `rkyv-js` is imported *only* by modules that
take a **non-scalar argument** (to `r.encode` it); the benchmark has none, so
take a **non-scalar argument** (to archive it into the input buffer), and then
only its encoder-only `rkyv-js/encode` entry; the benchmark has none, so
nothing extra ships.

## Notes
Expand Down
10 changes: 8 additions & 2 deletions benchmark/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ cd "$(dirname "$0")"
mkdir -p web/pkg/wasm-bindgen web/pkg/wasm-zero

echo ">> building wasm-bindgen suite (wasm-pack)"
wasm-pack build wasm-bindgen \
# RUSTFLAGS (even empty) replaces all config-file rustflags:
# the repo root .cargo/config.toml sets +atomics,+bulk-memory for the rayon demos,
# which would inflate this module and skew the size comparison.
RUSTFLAGS="" wasm-pack build wasm-bindgen \
--target web --release \
--out-dir ../web/pkg/wasm-bindgen \
--out-name bench_wasm_bindgen

echo ">> building wasm-zero suite (cargo, then bindings from the wasm metadata)"
( cd wasm-zero && cargo build --release )
# RUSTFLAGS replaces all config-file rustflags: the repo root .cargo/config.toml
# sets +atomics,+bulk-memory for the rayon demos, which would make this module
# import a shared memory and break the (non-threaded) generated loader.
( cd wasm-zero && RUSTFLAGS="-C panic=abort" cargo build --release )

# Generate bindings from the __wasm_zero custom section of the compiled wasm,
# then ship a copy with that section stripped (it's build-time-only data).
Expand Down
6 changes: 4 additions & 2 deletions benchmark/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
window.Foo = class { bar() {} };
</script>

<!-- rkyv-js isn't on npm; build it from GitHub source via esm.sh. -->
<!-- Resolve the bare `rkyv-js` specifiers (npm package, served via esm.sh).
The prefix entry covers subpath imports like `rkyv-js/encode`. -->
<script type="importmap">
{
"imports": {
"rkyv-js": "https://esm.sh/gh/cometkim/rkyv-js@2c3fc14"
"rkyv-js": "https://esm.sh/rkyv-js@0.3.0",
"rkyv-js/": "https://esm.sh/rkyv-js@0.3.0/"
}
}
</script>
Expand Down
2 changes: 1 addition & 1 deletion crates/wasm_zero/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ rkyv.workspace = true
bytecheck = "0.8.2"

[build-dependencies]
rkyv-js-codegen = { git = "https://github.com/cometkim/rkyv-js" }
rkyv-js-codegen = "0.3.0"
14 changes: 14 additions & 0 deletions crates/wasm_zero/src/mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ pub unsafe fn read_buffer<'a>(ptr: *const u8) -> &'a [u8] {
}

/// Parse the buffer
///
/// rkyv reads the archive **in place**, so `bytes` must satisfy
/// the archived type's alignment.
///
/// Buffers from [`read_buffer`] are always 16-aligned,
/// which covers every rkyv archived type, so the generated bindings unarchive with no copy at all.
///
/// Any other caller is made correct by copying into aligned scratch first.
///
/// # SAFETY
/// the pointer
pub unsafe fn parse_buffer<T>(bytes: &[u8]) -> Result<T, ErrorCode>
Expand All @@ -78,6 +87,11 @@ where
T::Archived: for<'a> CheckBytes<HighValidator<'a, rancor::Error>>
+ Deserialize<T, HighDeserializer<rancor::Error>>,
{
if (bytes.as_ptr() as usize).is_multiple_of(ALIGNMENT) {
return rkyv::from_bytes::<T, rancor::Error>(bytes)
.or(Err(ErrorCode::UnarchivingError));
}

// AlignedVec guarantees 16-byte alignment — enough for all rkyv types
let mut aligned = AlignedVec::<16>::new();
aligned.extend_from_slice(bytes);
Expand Down
97 changes: 82 additions & 15 deletions crates/wasm_zero_build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//! runtime is needed to decode.
//!
//! rkyv-js is imported only when a function takes a **non-scalar argument**
//! (`String`/struct/…), to `r.encode` it into the input buffer (hand-rolling
//! (`String`/struct/…), to codec-encode it into the input buffer (hand-rolling
//! the rkyv *writer* is out of scope). Scalar args pass directly as wasm params,
//! and scalar/unit returns come back as the wasm function's value.
//!
Expand Down Expand Up @@ -510,7 +510,9 @@ fn render_module(typed: bool, structs: &StructMap, funcs: &[FnMeta]) -> String {
let mut out = String::new();
out.push_str("// Auto-generated by wasm_zero. Do not edit by hand.\n");
if needs_rkyv {
out.push_str("import * as r from 'rkyv-js';\n");
// Encoder-only entry: the read path is hand-rolled, so the generated
// module never needs rkyv-js's decode/access machinery.
out.push_str("import * as r from 'rkyv-js/encode';\n");
}
out.push('\n');

Expand Down Expand Up @@ -559,6 +561,20 @@ fn render_module(typed: bool, structs: &StructMap, funcs: &[FnMeta]) -> String {
}
out.push_str("});\n\n");
}

// One hoisted codec per rkyv-encoded function: containers are
// constructed once at module setup instead of on every call.
out.push_str("// ---- per-function argument codecs ----\n");
for f in funcs {
if !f.args.is_empty() && !f.args.iter().all(|(_, ty)| is_scalar(ty)) {
out.push_str(&format!(
"const __argCodec_{} = {};\n",
f.name,
args_codec(&f.args, structs)
));
}
}
out.push('\n');
}

// One decoder per struct: reads each field at its archived offset.
Expand Down Expand Up @@ -591,25 +607,39 @@ fn render_module(typed: bool, structs: &StructMap, funcs: &[FnMeta]) -> String {
}

// ---- client ----
// The argument writer archives in place into the input buffer, so it is
// bound to that region of wasm memory and goes stale — along with the
// views — whenever the memory grows.
let writer_decl = if needs_rkyv {
format!(
"\x20 let argWriter{} = null; // fixed rkyv writer over the input buffer\n",
t(": r.RkyvWriter | null"),
)
} else {
String::new()
};
let writer_reset = if needs_rkyv { " argWriter = null;" } else { "" };
out.push_str(&format!(
"export function bindWasmZero(wasm{}) {{\n\
\x20 const outPtr = wasm.malloc(HEADER + MAX_BUFFER_SIZE);\n\
\x20 let inPtr = 0; // lazily allocated for non-scalar args\n\
{}\
\x20 let buf = wasm.memory.buffer, dv = new DataView(buf), u8 = new Uint8Array(buf);\n\
\x20 function views() {{ if (buf !== wasm.memory.buffer) {{ buf = wasm.memory.buffer; dv = new DataView(buf); u8 = new Uint8Array(buf); }} }}\n\n\
\x20 function views() {{ if (buf !== wasm.memory.buffer) {{ buf = wasm.memory.buffer; dv = new DataView(buf); u8 = new Uint8Array(buf);{} }} }}\n\n\
\x20 function invoke(shim{}, argCodec{}, argValue{}, directArgs{}) {{\n\
\x20 if (directArgs !== null) return wasm[shim](...directArgs, outPtr);\n\
\x20 if (argCodec === null) return wasm[shim](outPtr);\n",
t(": any"), t(": string"), t(": any"), t(": any"), t(": any"),
t(": any"), writer_decl, writer_reset,
t(": string"), t(": any"), t(": any"), t(": any"),
));
if needs_rkyv {
out.push_str(
" \x20 const inBytes = r.encode(argCodec, argValue);\n\
\x20 if (inBytes.length > MAX_BUFFER_SIZE) throw new RangeError('wasm_zero: input too large');\n\
\x20 if (inPtr === 0) inPtr = wasm.malloc(HEADER + MAX_BUFFER_SIZE);\n\
" \x20 if (inPtr === 0) inPtr = wasm.malloc(HEADER + MAX_BUFFER_SIZE);\n\
\x20 views();\n\
\x20 dv.setUint32(inPtr, inBytes.length, true);\n\
\x20 u8.set(inBytes, inPtr + HEADER);\n\
\x20 if (argWriter === null) argWriter = new r.RkyvWriter({ buffer: u8.subarray(inPtr + HEADER, inPtr + HEADER + MAX_BUFFER_SIZE) });\n\
\x20 argWriter.reset();\n\
\x20 argCodec.encodeInto(argWriter, argValue); // archived in place; overflow throws RangeError\n\
\x20 dv.setUint32(inPtr, argWriter.pos, true);\n\
\x20 return wasm[shim](inPtr, outPtr);\n",
);
} else {
Expand Down Expand Up @@ -648,7 +678,8 @@ fn render_module(typed: bool, structs: &StructMap, funcs: &[FnMeta]) -> String {
\x20 const {{ instance }} = await WebAssembly.instantiateStreaming(fetch(wasmUrl), importObject);\n\
\x20 return bindWasmZero(instance.exports);\n\
}}\n",
t(": string | URL"), t("?: WebAssembly.Imports"),
t(": string | URL"),
t("?: WebAssembly.Imports"),
));

out
Expand Down Expand Up @@ -697,7 +728,8 @@ fn render_method(f: &FnMeta, structs: &StructMap, typed: bool) -> String {
return format!(" {name}({params}){ann} {{ return {expr}; }},\n");
}

// Arg mode: nullary / all-scalar (direct) / non-scalar (rkyv-encoded).
// Arg mode: nullary / all-scalar (direct) / non-scalar (rkyv-encoded,
// via the module-level hoisted codec).
let (arg_codec, arg_value, direct_args) = if args.is_empty() {
("null".into(), "null".into(), "null".into())
} else if args.iter().all(|(_, t)| is_scalar(t)) {
Expand All @@ -707,7 +739,7 @@ fn render_method(f: &FnMeta, structs: &StructMap, typed: bool) -> String {
[(n, _)] => n.clone(),
_ => format!("[{}]", names_csv()),
};
(args_codec(args, structs), value, "null".into())
(format!("__argCodec_{name}"), value, "null".into())
};

let size = archived(ret, structs).0;
Expand Down Expand Up @@ -840,11 +872,46 @@ mod tests {
blob.extend(record(&["1", "struct", "P", &pair("x", "u32")]));
blob.extend(record(&["1", "fn", "shout", "String", &pair("msg", "String")]));
let g = generate_bindings(&fake_wasm(&blob)).unwrap();
assert!(g.js.contains("import * as r from 'rkyv-js'"));
assert!(g.js.contains("import * as r from 'rkyv-js/encode'"));
// Arg codec built once at module setup, not per call.
assert!(g.js.contains("const __argCodec_shout = r.string;"));
assert!(g.js.contains(
"shout(msg) { return call(\"__wasm_zero_shout\", 8, (dv, u8, p) => rdStr(dv, u8, p), r.string, msg, null); }"
"shout(msg) { return call(\"__wasm_zero_shout\", 8, (dv, u8, p) => rdStr(dv, u8, p), __argCodec_shout, msg, null); }"
));
assert!(g.js.contains("r.encode(argCodec, argValue)"));
// Arguments archive in place through a fixed writer over the wasm
// input buffer — no intermediate encode buffer, no copy.
assert!(g.js.contains("argCodec.encodeInto(argWriter, argValue)"));
assert!(g.js.contains("argWriter = new r.RkyvWriter({ buffer: u8.subarray(inPtr + HEADER, inPtr + HEADER + MAX_BUFFER_SIZE) })"));
assert!(g.js.contains("dv.setUint32(inPtr, argWriter.pos, true)"));
}

#[test]
fn multi_arg_codec_is_a_hoisted_tuple() {
let mut blob = Vec::new();
blob.extend(record(&[
"1",
"fn",
"send",
"u32",
&pair("tags", "Vec<u32>"),
&pair("note", "String"),
]));
let g = generate_bindings(&fake_wasm(&blob)).unwrap();
assert!(g.js.contains("const __argCodec_send = r.tuple(r.vec(r.u32), r.string);"));

// Scalar-only modules never import rkyv-js and keep the writer-free path.
let mut scalar_blob = Vec::new();
scalar_blob.extend(record(&[
"1",
"fn",
"add",
"i32",
&pair("a", "i32"),
&pair("b", "i32"),
]));
let scalar = generate_bindings(&fake_wasm(&scalar_blob)).unwrap();
assert!(!scalar.js.contains("rkyv-js"));
assert!(!scalar.js.contains("argWriter"));
}

#[test]
Expand Down
Loading