Running WASM in a Browser
Two paths deliver a rontolisp WASM build to a browser:
- Components via
jco transpile— turns a component into plain JavaScript modules whose exports become JavaScript functions. - Reactor modules by hand — a
--no-wasi(wasm-GC, full language) or--no-gc(scalar-only) core module has no imports either way, soWebAssembly.instantiate+instance.exportsis the whole host side, byte for byte the same JavaScript for both backends. Node and the browser use the same code.
Running a Component in a Browser (jco)
A component is not a wasmtime-only artifact. jco transpile turns one into
JavaScript, and the result runs in a browser — the exports become plain
JavaScript functions.
The example used throughout this section is count-vowels: one exported
function taking a string and returning how many vowels it contains.
It is pure compute — no cons, no I/O — so it stays inside the
--no-gc subset and compiles to a component
with zero imports. jco camel-cases the component-model export name, so
count-vowels arrives as countVowels. (Verified with jco 1.25.2 on Chrome
149.) The same program driven from a Node host and from a Java host, with the
export declared in WIT instead of wasm-export, is
examples/count-vowels.
A --no-gc --component needs nothing at all. Its world has no imports,
so jco emits one self-contained ES module — the core WASM base64-inlined
inside it, about 90 KB for count-vowels — with no import statements of
its own. The page supplies no shim, no import map and no polyfill:
rontolisp count-vowels.lisp --no-gc --component -o cv.wasm
npx @bytecodealliance/jco transpile cv.wasm -o dist
<script type="module">
const { countVowels } = await import('./dist/cv.js');
console.log(countVowels('Hello, World!')); // 3
</script>
A printing --no-gc --component cannot run through jco yet. Its
print micro-adapter
imports wasi:cli/stdout@0.3.0 and lifts every export async, so it hits
the same jco gaps as the GC component below (jco cannot call an
async-lifted export, and its future runtime is incomplete) — and the
WASI 0.3 shim is Node-only anyway. Keep the program print-free if the
component's destination is jco or a browser; the
plain module path with a
hand-written import object is unaffected.
A wasm-GC --component loads and computes, but cannot print there yet.
Chrome supports wasm-GC, JSPI and the canonical ABI, and the component's
synchronous exports return correct values. Two gaps are in the way of the
rest, both on the JavaScript side (wasmtime runs all of it):
- The WASI 0.3 imports it needs have no browser implementation:
@bytecodealliance/preview3-shimdeclares only anodecondition in its packageexportsand pulls innode:worker_threads,node:net,node:http, ... A page must hand-write a stand-in for the nine members jco destructures at module top level —environment.getEnvironment,stdout.writeViaStream,stderr.writeViaStream,stdin.readViaStream,monotonicClock.now,systemClock.now,preopens.getDirectories,types.Descriptor,random.getRandomU64— which for a pure-compute export only have to exist. - Printing then fails inside jco's own generated code, which references
FutureReadableEnd/FutureWritableEnd/FutureEndbut defines none of them (ReferenceError: FutureReadableEnd is not defined). It is reached throughwasi:cli/stdout'swrite-via-stream, whose WIT result is afuture. Separately, jco cannot yet call an async export (its 0.3 async ABI gap again), which is what an:async tI/O export is.
Node is the weaker host here: Node 22 has no JSPI
(WebAssembly.Suspending is not a constructor), so it cannot even
instantiate a transpiled GC component, while Chrome can.
Reactor Modules by Hand
A reactor module (--no-wasi or --no-gc) imports nothing, so the whole
host side is "instantiate, then call the exports" — and it is the same code
in Node and the browser. Here is a complete, copy-paste example end to end.
Start with a small kit of three exports:
Compile it with --no-gc so it runs on any engine; everything unreachable from
the exports is dropped without asking, which here leaves a whole module of ~200
bytes:
rontolisp mathkit.lisp --no-gc -o mathkit.wasm
On Node 18+, save this as run.mjs and run node run.mjs:
import { readFile } from 'node:fs/promises';
// Node reads the .wasm from disk. In a browser, use the streaming fetch shown below.
const bytes = await readFile(new URL('./mathkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes); // no import object
const ex = instance.exports;
console.log(ex.fact(10)); // 3628800
console.log(ex.area(2)); // 12.566370614359172
console.log(Boolean(ex['in-range'](5, 0, 10))); // true (:bool crosses as 0 / 1)
console.log(Boolean(ex['in-range'](42, 0, 10))); // false
3628800
12.566370614359172
true
false
The browser differs only in how the bytes are loaded — instantiateStreaming
takes a fetch directly — so a whole page is:
<!doctype html>
<script type="module">
const { instance } = await WebAssembly.instantiateStreaming(fetch('./mathkit.wasm'));
const ex = instance.exports;
document.body.textContent = `fact(10) = ${ex.fact(10)}, area(2) = ${ex.area(2)}`;
</script>
A few boundary details worth knowing:
- A hyphenated Lisp name such as
in-rangeis not a valid JavaScript identifier, so reach it with bracket access:ex['in-range'](...). :int/:floatarrive as plain JS numbers;:boolcrosses as ani32(0/1), so wrap it inBoolean(...)for a real JS boolean.- A
--no-gcmodule runs on any WebAssembly engine; a GC--no-wasimodule needs a wasm-GC-capable one (Node 22+, current browsers). The JavaScript above is byte-for-byte identical for both — swap the compile flag and nothing else changes.
Proof, not just assertion — recompile the same source with --no-wasi and
run the unchanged run.mjs:
rontolisp mathkit.lisp --no-wasi -o mathkit.wasm
node run.mjs
3628800
12.566370614359172
true
false
Nothing above was --no-gc-specific: mathkit.lisp never leaves the
non-GC subset, so it is one of the (many) programs that compile cleanly
under either backend. A program that needs the full language — cons,
string-upcase, hash-tables, defstruct, ... — simply requires
--no-wasi; it is not a fallback or a lesser path, only the backend a
wasm-GC-capable engine (Node 22+, every current browser) runs.
Passing strings (:string)
The scalar example above needs no memory because :int/:float/:bool
cross the boundary as plain numbers. A :string instead passes a (ptr, len) pair through the module's exported memory: the host writes the
argument bytes into memory (at an offset reserved by the exported
__ronto_alloc(size) bump allocator), passes (ptr, len), then decodes
the (ptr, len) the export returns.
:string works under --no-gc, so the module still runs on any engine
— as long as the function stays within the non-GC string subset (see the
eligible subset). A greeting builder is
enough to show the protocol:
rontolisp greetkit.lisp --no-gc -o greetkit.wasm
import { readFile } from 'node:fs/promises';
const bytes = await readFile(new URL('./greetkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes); // no import object
const ex = instance.exports;
const enc = new TextEncoder(), dec = new TextDecoder();
// Copy a JS string into linear memory; return its (ptr, len).
function write(str) {
const b = enc.encode(str);
const ptr = ex.__ronto_alloc(b.length);
new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
return [ptr, b.length];
}
// Decode a (ptr, len) result. Re-read ex.memory.buffer AFTER the call: a call may grow
// memory, which detaches the previous ArrayBuffer.
const read = (ptr, len) => dec.decode(new Uint8Array(ex.memory.buffer, ptr, len));
console.log(read(...ex.greet(...write('rontolisp')))); // Hello, rontolisp!
Hello, rontolisp!
With --no-gc --component
the same :string export instead crosses as a typed component-model
string, and all of the host-side glue above disappears (the canonical ABI
does the copying, and a post-return function keeps the heap flat).
Richer string functions (string-upcase, subseq, string=, …) are
outside the non-GC subset; using one means compiling for the wasm-GC
backend (--no-wasi) instead — the boundary protocol is identical, only
the engine must be wasm-GC capable. The :s-expr example below shows that
path.
Passing lists (:s-expr)
A :s-expr carries any Lisp value as s-expression text: the module
parses the input with its embedded reader and prints the result back, over
the same (ptr, len) / __ronto_alloc protocol. That reader/printer/cons
machinery is wasm-GC only, so :s-expr (and the richer string
functions above) need --no-wasi and a wasm-GC-capable engine (Node 22+, a
current browser):
rontolisp textkit.lisp --no-wasi -o textkit.wasm
// Same instantiate + write/read helper as above (textkit.wasm needs a wasm-GC engine).
console.log(read(...ex.shout(...write('hello')))); // HELLO
console.log(read(...ex.rev(...write('("a" "b" "c")')))); // ("c" "b" "a")
HELLO
("c" "b" "a")
In the browser only the loading line changes
(WebAssembly.instantiateStreaming(fetch(...))); the
write/read/memory/__ronto_alloc logic is identical. A function that
returns a multi-value (ptr, len) shows up in JS as a two-element array,
hence read(...ex.shout(...)).