WASM (Rust <-> JS)
The wasm feature adds direct Rust <-> JavaScript conversion for generated protocol types.
This is intended for wasm-bindgen targets (browser and other JS runtimes) where you want to work with protocol objects in JS without JSON as a transport layer.
The wasm layer is a binding over the Rust packet engine, not a separate implementation of packet codecs in JavaScript. For the shared architectural model behind this split, see Integrations.
Motivation
The main reason to use wasm is to avoid extra conversion layers such as:
- Rust binary -> Rust struct -> JSON string
- JSON string -> JS object
and then the reverse on encode.
With wasm, conversion is done directly between Rust values and JS values (JsValue):
- less CPU spent on JSON serialization/parsing glue
- fewer temporary allocations related to string conversion
- strict numeric mapping for wide integers and float bit-exact roundtrips
As with any optimization, exact speedups depend on workload. For packet-heavy wasm integrations, this removes a meaningful class of overhead.
Enabling The Feature
Enable wasm in your protocol crate:
[dependencies]
brec = { version = "...", features = ["wasm", "bincode"] }
bincode is typically used because payload support in generated WASM aggregators expects payload variants to be #[payload(bincode)].
Quick Start (wasm-bindgen Module)
If you want to expose your protocol as a wasm module and use protocol objects directly in JS:
- In your protocol crate, enable
brecwithwasmand your payload codec (usuallybincode). - Define blocks with
#[brec::block]. - Define payloads with
#[payload(bincode)]. - For nested custom payload field types, derive
brec::Wasm. - Call
brec::generate!()to generateBlock,Payload, andPacketglue. - In your wasm bindings crate, expose functions with
#[wasm_bindgen]and call generated helpers: Block::decode_wasm/Block::encode_wasmPayload::decode_wasm/Payload::encode_wasmPacket::decode_wasm/Packet::encode_wasm- Build the bindings crate with
wasm-pack(or your preferred wasm-bindgen workflow) and consume it from JS.
Build example (from the e2e/wasm workspace):
cd e2e/wasm/binding
wasm-pack build --dev --target web --out-dir pkg --out-name wasmjs
Minimal shape:
// protocol crate
#[brec::block]
pub struct MyBlock {
pub id: u64,
}
#[derive(serde::Serialize, serde::Deserialize, brec::Wasm)]
pub struct Inner {
pub tag: String,
}
#[payload(bincode)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MyPayload {
pub inner: Inner,
}
brec::generate!();
// bindings crate
#[wasm_bindgen]
pub fn decode_packet(buf: &[u8]) -> Result<JsValue, JsValue> {
let mut ctx = ();
Packet::decode_wasm(buf, &mut ctx)
.map_err(|e| JsValue::from_str(&format!("decode packet: {e}")))
}
#[wasm_bindgen]
pub fn encode_packet(packet: JsValue) -> Result<Vec<u8>, JsValue> {
let mut ctx = ();
let mut out = Vec::new();
Packet::encode_wasm(packet, &mut out, &mut ctx)
.map_err(|e| JsValue::from_str(&format!("encode packet: {e}")))?;
Ok(out)
}
Reference implementation in this repository:
- WASM shared e2e workspace:
e2e/wasm/ - Protocol crate:
e2e/wasm/protocol - Binding crate:
e2e/wasm/binding - Browser client:
e2e/wasm/clients/browser - Node client:
e2e/wasm/clients/node - End-to-end scripts:
e2e/wasm/clients/browser/test.sh,e2e/wasm/clients/node/test.sh,e2e/wasm/test.sh
Direct links:
- https://github.com/icsmw/brec/tree/main/e2e/wasm
- https://github.com/icsmw/brec/blob/main/e2e/wasm/protocol/src/lib.rs
- https://github.com/icsmw/brec/blob/main/e2e/wasm/binding/src/lib.rs
- https://github.com/icsmw/brec/blob/main/e2e/wasm/clients/browser/src/main.js
- https://github.com/icsmw/brec/blob/main/e2e/wasm/clients/node/src/main.js
- https://github.com/icsmw/brec/blob/main/e2e/wasm/test.sh
Required Macros For Payload Types
For payload WASM conversion, nested custom Rust types must implement brec::WasmConvert.
Use:
#[derive(brec::Wasm)]for nested structs/enums used inside payload fields#[payload(bincode)]for payloads supported by the generated Payload WASM aggregator
Example:
#[derive(serde::Serialize, serde::Deserialize, brec::Wasm, Clone, Debug)]
pub struct Inner {
pub id: u32,
pub flag: bool,
}
#[payload(bincode)]
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct MyPayload {
pub inner: Inner,
}
If a payload variant is not #[payload(bincode)], the generated WASM payload aggregator returns an error for that variant.
Rust -> JS Reflection
The generated WASM API uses explicit object shapes.
Block enum shape
Each block is represented as an object with exactly one key:
{ "MyBlock": { /* block fields */ } }
Payload enum shape
Each payload is represented as an object with exactly one key:
{ "MyPayload": { /* payload fields */ } }
Default payloads (when enabled) are:
{ "Bytes": [/* u8 array */] }
{ "String": "..." }
Packet shape
PacketDef WASM conversion uses:
{
blocks: Array<object>, // each element is one-key Block object
payload: object | null // one-key Payload object, null, or undefined on input
}
Data Contract On The Consumer Side
On the JavaScript side you receive plain runtime values (object, Array, BigInt, string, etc.), not generated runtime types.
What brec guarantees:
- The decoded object shape follows the protocol definition.
- If a variant is
BlockA, the object contains exactlyBlockAfields. - If a variant is
PayloadA, the object contains exactlyPayloadAfields. - Field names are preserved exactly as defined in your Rust protocol types.
What brec does not do for you:
- It does not generate runtime validators in JS.
- It does not enforce TypeScript compile-time typing by itself.
Responsibility split:
brecvalidates protocol data while decoding and produces protocol-shaped objects.- Your application is responsible for additional business-level validation and optional static typing wrappers.
How to read these objects in JS:
const packet = decode_packet(bytes);
for (const blockObj of packet.blocks) {
const [blockKind, blockFields] = Object.entries(blockObj)[0];
// blockKind -> "BlockA", blockFields -> { ...fields from protocol... }
}
if (packet.payload != null) {
const [payloadKind, payloadFields] = Object.entries(packet.payload)[0];
// payloadKind -> "PayloadA", payloadFields -> { ...fields from protocol... }
}
Numeric Mapping Rules
To keep conversion lossless:
i64,u64,i128,u128are mapped via JSBigIntf32is transferred as itsu32bit patternf64is transferred as itsu64bit pattern via JSBigInt
This preserves exact Rust values across JS roundtrips, including edge cases.
Why Float Bit Patterns?
f32/f64 are encoded via bit patterns rather than plain JS Number to avoid accidental precision loss and to preserve exact payload values end-to-end.
This is especially important when values are serialized/deserialized many times across runtime boundaries.
Generated Helpers
Generated protocol types expose WASM helper methods:
decode_wasm(...)- bytes -> JS objectencode_wasm(...)- JS object -> bytes
For packet and payload paths, context is passed explicitly (ctx) exactly like in regular Rust encode/decode flows.
JavaScript Usage Pattern
Typical browser-side flow:
import init, { decode_packet, encode_packet } from 'wasmjs';
await init();
const packet = decode_packet(inBytes); // JS object
const outBytes = encode_packet(packet); // Uint8Array-compatible bytes
For large integer fields (i64/u64/i128/u128), provide BigInt values from JS:
const payload = {
PayloadA: {
field_u64: 42n,
field_i128: -123n,
},
};
Error Behavior
WASM conversion errors are surfaced as conversion/shape errors (for example: invalid object shape, missing field, invalid field type/range).
Common causes:
- enum wrapper object has zero or multiple keys
BigInt-required field receivesNumber- tuple/array field shape mismatch
- payload variant not marked with
#[payload(bincode)]
Runtime Notes
- Browser wasm modules are typically built with
wasm-pack --target web. - In Node runtimes with wasm-bindgen, the same object conversion rules apply.
- The conversion API is independent from transport; you can use WebSocket, fetch, SharedArrayBuffer pipelines, etc.
Limitations
- Source-based Rust coverage (
cargo llvm-cov) forwasm32-unknown-unknownis not generally available in standard setups. - If you need coverage in browser tests, treat JS-side coverage and Rust-native coverage as separate pipelines.