This backend compiles Aver to standalone .wasm modules with a typed ABI:
Int -> i64Float -> f64Bool -> i32- heap-backed values (
String,List,Map,Vector, records, variants, wrappers) ->i32pointer
Default output uses the aver/* import ABI. That keeps the generated module host-neutral: the same .wasm can run under the built-in wasmtime host, a browser JS shim, or a custom embedder.
aver compile app.av --target wasmEmitsaver/*imports such asaver/console_print,aver/time_unixMs,aver/format_value.aver compile app.av --target wasm --wasm-opt ozPost-processes the emitted module withwasm-opt -Ozfor smaller binaries.aver compile app.av --target wasm --wasm-opt o3Post-processes the emitted module withwasm-opt -O3for speed-oriented optimization.aver compile app.av --target wasm --adapter wasiEmits WASI imports for standalonewasmtime.
--wasm-opt requires binaryen (wasm-opt) to be installed. The toolchain passes the required WASM feature flags automatically because Aver modules use bulk-memory ops and multi-value imports.
String literals are deduplicated at compile time. Each unique string appears once in the data section regardless of how many times it is referenced in source code.
aver run app.av --wasm compiles with the aver/* ABI and executes the module with a built-in wasmtime host in src/main/commands.rs.
The built-in host currently provides:
Console.*Terminal.*Random.intTime.now,Time.unixMs,Time.sleepPrint.value,Format.valueFloat.sin,Float.cos,Float.atan2,Float.pow
The canonical import table lives in abi.rs.
A few practical rules:
- strings cross the boundary as
ptr + len - heap strings inside Aver memory are string objects: 8-byte header, then UTF-8 bytes
Print.value/Format.valuetake(tag: i32, value: i64)Terminal.readKeyreturns(ptr, len)forSome(String)and(-1, 0)forOption.NoneTerminal.sizereturns(width: i32, height: i32)and codegen wraps that intoTerminal.Size
The WASM backend uses a single bump-heap allocator ($alloc) with boundary compaction at function return and TCO iteration boundaries. No separate GC runtime — the full model is ~1.5 KB of emitted WASM.
Function return: collect_begin(mark) → retain_i32(result) deep-copies reachable objects to a temp area → collect_end() rebases internal pointers and copies back → rebase_i32(result). Dead objects between mark and heap_ptr are reclaimed.
Self-call TCO (yard semantics): an iter_mark is saved at each loop iteration. If the iteration allocated very little (≤256 bytes), compaction is skipped entirely (O(1) — accumulator pattern like list building). Otherwise, full compaction from the function's fn_mark reclaims dead objects from previous iterations (replacement pattern like game loops).
Mutual TCO (watermark): mutual tail-call trampolines use a gc_watermark instead of per-iteration skip heuristics. Compaction triggers when accumulated garbage (heap_ptr - watermark) exceeds 16KB since the last collection, then resets the watermark. This handles nested calls that truncate back to their own boundary (which would mask per-iteration growth from iter_mark).
Thin/parent-thin frames: small pure functions (leaf computations, dispatch) skip boundary work entirely — no mark saved, no compaction on return.
Exported globals: modules export $heap_ptr (bump allocator position) for host-side memory inspection, and $alloc(size: i32) -> i32 so hosts can allocate guest memory safely for strings returned from host imports.
aver.tomlpolicy — the WASM binary does not embed runtime policy. Effect restrictions (hosts,paths,keys) are the host's responsibility. The built-in host does not yet readaver.toml. Independence mode (cancelvscomplete) has no effect since WASM execution is single-threaded.- Module graph is compile-time only — regular multi-module
depends [...]works when the full graph resolves under the chosen--module-root, but the backend emits one standalone module, not separately linked WASM modules. - Services — the built-in host provides Console, Terminal, Random, Time, and math. Disk, Http, Tcp, Env, and Args are not available.
Option.withDefault(Vector.get(v, i), literal)→ inline bounds check + directi64.load, no Option wrapper allocationMap.setwith unique keys → prepend only (no rebuild), O(1) insert- String concatenation uses
memory.copyfor bulk byte transfer
This is enough to run console-style examples compiled with aver compile hello.av --target wasm:
<pre id="out"></pre>
<script type="module">
const out = document.querySelector("#out");
const td = new TextDecoder();
const te = new TextEncoder();
let instance;
const mem = () => new Uint8Array(instance.exports.memory.buffer);
const readBytes = (ptr, len) => mem().slice(ptr, ptr + len);
const readStringObj = (ptr) => {
const view = new DataView(instance.exports.memory.buffer);
const len = Number(view.getBigUint64(ptr, true) & 0xffffffffn);
return td.decode(readBytes(ptr + 8, len));
};
const formatTagged = (tag, val) => {
switch (tag) {
case 0: return BigInt.asIntN(64, val).toString();
case 1: {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setBigUint64(0, BigInt.asUintN(64, val), true);
return String(view.getFloat64(0, true));
}
case 2: return val !== 0n ? "true" : "false";
case 3: return readStringObj(Number(val));
default: return String(val);
}
};
const writeGuestString = (text) => {
const bytes = te.encode(text);
if (bytes.length <= 32) { mem().set(bytes, 96); return [96, bytes.length]; }
const ptr = instance.exports.alloc(bytes.length);
mem().set(bytes, ptr);
return [ptr, bytes.length];
};
const imports = {
aver: {
console_print(ptr, len) { out.textContent += td.decode(readBytes(ptr, len)); },
console_error(ptr, len) { out.textContent += td.decode(readBytes(ptr, len)); },
console_readLine() { return writeGuestString(""); },
print_value(tag, val) { out.textContent += formatTagged(tag, val); },
format_value(tag, val) { return writeGuestString(formatTagged(tag, val)); },
random_int(min) { return min; },
time_now() { return writeGuestString(new Date().toISOString()); },
time_unixMs() { return BigInt(Date.now()); },
time_sleep() {},
math_sin(x) { return Math.sin(x); },
math_cos(x) { return Math.cos(x); },
math_atan2(y, x) { return Math.atan2(y, x); },
math_pow(base, exp) { return Math.pow(base, exp); },
},
};
({ instance } = await WebAssembly.instantiateStreaming(fetch("/hello.wasm"), imports));
instance.exports._start();
</script>The same shape works in Node via WebAssembly.instantiate(...) with a Buffer.
There is also a real browser host in tools/website/playground/ that runs compiled Aver modules in-page. For interactive Terminal.readKey() workloads, serve the site with python3 tools/website/serve.py 4173 so the page gets the isolation headers needed for shared-memory input.
Terminal.* is available in the built-in wasmtime host and in the static website playground host.
For browsers, the rendering policy is still host-specific:
Terminal.printcan target a<pre>, DOM tree,<canvas>, or terminal emulator widgetTerminal.moveTo/clear/setColor/flushmap naturally to a retained text gridTerminal.readKeyshould be driven from browser keyboard events into a small queueenableRawMode/disableRawModeare usually "capture keyboard events" rather than literal TTY mode
The host in tools/website/playground/ is the reference implementation for that mapping.
From the repo root:
python3 tools/website/rebuild_playground.pyThis syncs mirrored game sources under tools/website/playground/sources/, rebuilds the shipped .wasm files with --wasm-opt oz, and refreshes the size labels shown on the website.