WASM
Overview
The WASM target produces a typed ES module loader for
wasm32-unknown-unknown builds of WeaveFFI cdylibs. The loader wraps
the raw exports in idiomatic JavaScript: per-module namespaces, struct
wrapper classes with getters, thrown Errors instead of error slots,
Promise-based async functions, and automatic string/bytes staging in
linear memory. TypeScript declarations describe the whole surface.
Because a wasm32-unknown-unknown module is single-threaded and has no
producer thread, callbacks and listeners are not supported; see
Capabilities and allow_unsupported.
What gets generated
| File | Purpose |
|---|---|
generated/wasm/weaveffi_wasm.js | ES module: memory helpers, struct wrapper classes, and the async loadWeaveffiWasm(url) loader returning typed bindings |
generated/wasm/weaveffi_wasm.d.ts | TypeScript declarations for the loader and every module namespace |
generated/wasm/package.json | npm package manifest (type: "module") |
generated/wasm/README.md | Quickstart and boundary conventions |
Type mapping
| IDL type | WASM boundary | JavaScript surface |
|---|---|---|
i32 / u32 | i32 | number |
i8 / i16 | i32 | number |
u8 / u16 | i32 | number |
i64 | i64 | BigInt |
u64 | i64 | BigInt |
f64 | f64 | number |
f32 | f32 | number |
bool | i32 | boolean (0/1 at the boundary) |
string | i32 pointer (NUL-terminated UTF-8) | string, staged via weaveffi_alloc |
bytes | i32 pointer + i32 length | Uint8Array copy |
handle / StructName | i32 pointer into linear memory (0 = null) | struct wrapper class with getters |
EnumName (plain, C-style) | i32 discriminant | number |
EnumName (rich / algebraic) | i32 pointer into linear memory (0 = null) | wrapper class (e.g. Shape) |
T? | 0 / null pointer; scalars boxed by pointer | T | null |
[T] | i32 pointer + i32 length | Array copy |
iter<T> | iterator handle + next out-param | drained into an Array |
Example IDL → generated code
The loader exports a single async entry point that fetches,
instantiates, and wraps a .wasm module:
import { loadWeaveffiWasm } from './weaveffi_wasm.js';
const api = await loadWeaveffiWasm('/your_library.wasm');
Functions are grouped by IDL module and have idiomatic signatures; strings, arrays, and error handling are taken care of inside the wrapper:
api.events.send_message('hello'); // throws Error on failure
const all = api.events.get_messages(); // iter<string> -> string[]
Structs come back as wrapper classes holding the native handle, with a
getter per field and a static create when the struct has a
constructor:
const result = await api.tasks.run_task('build');
console.log(result.id, result.value, result.success);
The raw exports stay reachable for anything not covered by the typed surface:
api._raw.weaveffi_alloc(16);
The generated weaveffi_wasm.d.ts mirrors all of this for TypeScript
consumers:
export interface WeaveffiWasmModule {
_raw: WebAssembly.Exports;
events: {
send_message(text: string): void;
get_messages(): string[];
};
}
export function loadWeaveffiWasm(url: string): Promise<WeaveffiWasmModule>;
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style enum stays an i32 discriminant (surfaced as a
number plus a frozen constants object), but a rich enum lowers to an
opaque object handle, an i32 pointer into linear memory, exactly
like a struct wrapper. The loader wraps it in a Shape class that owns
that handle for the lifetime of the module instance.
For a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }, the generated Shape class has
one static factory per variant, a tag getter, a getter per variant
field, and an explicit free() (there is no FinalizationRegistry on
this target):
class Shape {
constructor(wasm, handle) {
this._wasm = wasm;
this._handle = handle;
}
get tag() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_tag(this._handle);
return _r;
}
static empty(wasm) {
const _err = _allocErr(wasm);
const _r = wasm.weaveffi_shapes_Shape_Empty_new(_err);
_checkErr(wasm, _err);
_freeErr(wasm, _err);
return new Shape(wasm, _r);
}
static circle(wasm, radius) {
const _err = _allocErr(wasm);
const _r = wasm.weaveffi_shapes_Shape_Circle_new(radius, _err);
_checkErr(wasm, _err);
_freeErr(wasm, _err);
return new Shape(wasm, _r);
}
// ... rectangle(wasm, width, height), labeled(wasm, label, count) ...
get circleRadius() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_Circle_get_radius(this._handle);
return _r;
}
get labeledLabel() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_Labeled_get_label(this._handle);
return _takeCStr(wasm, _r);
}
// ... rectangleWidth, rectangleHeight, labeledCount ...
free() {
if (this._handle !== 0) {
this._wasm.weaveffi_shapes_Shape_destroy(this._handle);
this._handle = 0;
}
}
}
Shape.Tag = Object.freeze({
Empty: 0,
Circle: 1,
Rectangle: 2,
Labeled: 3,
});
The wasm instance is bound for you by the loader, so on the returned
API the factories take only their declared arguments. Under
api.shapes.Shape you get empty(), circle(radius),
rectangle(width, height), labeled(label, count), plus the frozen
Tag map:
shapes: {
// ...
Shape: {
empty: (...args) => Shape.empty(wasm, ...args),
circle: (...args) => Shape.circle(wasm, ...args),
rectangle: (...args) => Shape.rectangle(wasm, ...args),
labeled: (...args) => Shape.labeled(wasm, ...args),
Tag: Shape.Tag,
},
},
The active variant is read through the tag getter (no call
parentheses) and compared against api.shapes.Shape.Tag. Each variant
field is a camelCased getter: circleRadius, rectangleWidth,
rectangleHeight, labeledLabel, labeledCount. Functions that take
or return the enum pass the wrapper directly: describe(shape) reads
shape._handle, and scale(shape, factor) returns a fresh Shape.
The generated weaveffi_wasm.d.ts types the wrapper as an
export declare class:
export declare class Shape {
get tag(): number;
static readonly Tag: Readonly<{
Empty: 0;
Circle: 1;
Rectangle: 2;
Labeled: 3;
}>;
static empty(): Shape;
static circle(radius: number): Shape;
static rectangle(width: number, height: number): Shape;
static labeled(label: string, count: number): Shape;
get circleRadius(): number;
get rectangleWidth(): number;
get rectangleHeight(): number;
get labeledLabel(): string;
get labeledCount(): number;
free(): void;
}
A short round-trip that constructs a couple of variants, reads the tag and a
field, calls describe / scale, then frees the handles:
const api = await loadWeaveffiWasm('/shapes.wasm');
const circle = api.shapes.Shape.circle(2.0);
const label = api.shapes.Shape.labeled('unit', 3);
if (circle.tag === api.shapes.Shape.Tag.Circle) {
console.log(circle.circleRadius); // 2
}
console.log(api.shapes.describe(circle)); // native-rendered description
const bigger = api.shapes.scale(circle, 3.0); // a fresh Shape
// No FinalizationRegistry on this target. Free handles yourself.
circle.free();
label.free();
bigger.free();
Ownership: a Shape owns its native object. JavaScript has no
deterministic destructors here, so call free() when you are done;
otherwise the allocation lives until the module instance is dropped.
Async support
Async IDL functions return real Promises. The loader grows the
module’s __indirect_function_table and registers one JavaScript
trampoline per completion-callback signature using the
JS Type Reflection API
(new WebAssembly.Function(...)); each call stores its
resolve/reject pair in a context map keyed by an integer id:
run_task(name) {
return new Promise((resolve, reject) => {
const ctxId = _nextCtxId++;
_asyncContexts.set(ctxId, { resolve, reject, unwrap: (w, h) => new TaskResult(w, h) });
const [a0_p, a0_s] = _cstr(wasm, name);
wasm.weaveffi_tasks_run_task_async(a0_p, _cbPtr_i32_i32_i32, ctxId);
wasm.weaveffi_dealloc(a0_p, a0_s);
});
}
When the producer invokes the completion callback, the trampoline looks up the context, settles the promise, and removes the entry.
Two caveats apply:
WebAssembly.Functionrequires a runtime with JS Type Reflection (recent V8/SpiderMonkey; Chrome, Firefox, Node 16+, Deno).- The module is single-threaded: the producer must complete the
callback on the calling thread (e.g. an executor polled by the same
thread). A producer that spawns OS threads will not work on
wasm32-unknown-unknown.
Cancellable functions expose their cancel entry point as a plain
function in the same namespace (e.g. api.tasks.cancel_task(id)).
Capabilities and allow_unsupported
The WASM generator declares callbacks and listeners as unsupported in
its TargetCapabilities. If your IDL uses them, weaveffi generate
fails with an error listing the offending definitions rather than
silently skipping them.
To generate the rest of the surface anyway, opt in explicitly:
# weaveffi.toml
[wasm]
allow_unsupported = true
or inline in the IDL:
generators:
wasm:
allow_unsupported: true
With the opt-in, unsupported entry points are generated as explicit
throwing stubs (calling register_message_listener throws an
Error explaining that listeners need a native target), so the gap is
visible at the call site instead of failing silently.
Build instructions
macOS / Linux / Windows (cross-compilation, all hosts):
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release -p your_library
The resulting .wasm is in target/wasm32-unknown-unknown/release/.
Serve it over HTTP and load it with the generated helper:
<script type="module">
import { loadWeaveffiWasm } from './weaveffi_wasm.js';
const api = await loadWeaveffiWasm('/your_library.wasm');
</script>
Memory and ownership
- The wrapper stages strings, bytes, and arrays into linear memory with
the exported
weaveffi_alloc/weaveffi_deallocand releases them after the call; you don’t manage buffers for typed calls. - Producer-owned returns (strings, arrays, struct fields) are copied to
JavaScript values and freed via
weaveffi_free_string/weaveffi_deallocinside the wrapper. - Struct wrapper objects hold a native handle. JavaScript has no deterministic destructors; the underlying allocation lives until the module is dropped. Treat handles as owned by the module instance.
- Error slots are allocated, checked, and cleared internally; failures
surface as thrown
Errors with the producer’s code and message. - When you bypass the typed surface via
_raw, the conventions at the top ofweaveffi_wasm.jsapply and every alloc must be paired with a dealloc.
Troubleshooting
WebAssembly.Function is not a constructor: the runtime lacks JS Type Reflection. Use a current Chrome/Firefox/Node/Deno, or avoid async IDL functions for this target.LinkError: import object field 'env' is not a Function: the loader instantiates with an empty imports object. If your Rust crate imports host functions, extendloadWeaveffiWasmto pass them in.- An async call never settles: the producer must invoke the
completion callback on the same thread;
std::thread::spawndoes not exist onwasm32-unknown-unknown. - Out-of-memory after many
_rawcalls: every pointer returned from the module must be deallocated; the typed wrappers do this for you, raw calls do not. - The
.wasmfile fails to instantiate: the build artifact must bewasm32-unknown-unknown.wasm32-wasimodules require WASI imports and cannot run in the browser without a polyfill.