Compile to WASM
Give rontolisp an output path ending in .wasm with -o, and it compiles the
source to a WebAssembly binary instead of interpreting it. As with the JVM
backend, the output extension selects the target, and the binary is emitted by
hand without a third-party assembler:
echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o hello.wasm
wasmtime run -W gc hello.wasm
3
The WASM backend can produce several different output shapes, selected by compile flags. The next section is the map: pick your shape there, then read only that shape's section — each one is self-contained.
Choosing an Output
Two independent choices determine the shape of the output:
- Value model. By default, values live on the WebAssembly GC heap
(integers as
i31ref, floats boxed in a struct), which supports the full language but requires a wasm-GC capable runtime (wasmtime 14+, Node 22+, current browsers).--no-gcinstead lowers a pure-compute subset of the language onto unboxedi64/f64scalars and linear-memory strings — the result is a plain MVP module that runs on any WebAssembly engine and is orders of magnitude smaller. - Packaging. By default the output is a WASI Preview 1 core module.
--componentwraps it as a component: on the GC path a WASI 0.3 component with full I/O over the async canonical ABI, on the--no-gcpath a compact typed reactor component that runs with no host flags at all. On the Preview 1 GC path,--no-wasiinstead drops the WASI imports, turning the module into a pure-compute library ("reactor") a host can instantiate with no import object.
Crossing the two axes gives the five shapes:
| Output shape | Flags | Language | Runs on | Pick it for |
|---|---|---|---|---|
| WASI command module | (none) | full | wasm-GC engine with WASI Preview 1 (wasmtime run -W gc) | running a whole program from the command line |
| Library (reactor) module | --no-wasi | full (pure-compute exports) | any wasm-GC engine, no imports needed (Node 22+, current browsers) | calling Lisp functions from JavaScript |
| WASI 0.3 component | --component | full, plus component-only I/O (rontolisp:fetch, TCP sockets) | wasmtime 46+ (with the flags below) or another component host with wasm-GC | typed component exports plus real I/O |
| Plain core module | --no-gc | numeric/string subset | any WebAssembly engine, even without wasm-GC or SIMD | tiny, dependency-free compute kernels |
| Compact typed component | --no-gc --component | numeric/string subset | any component host, zero flags | tiny typed components |
Rule of thumb: pick the value model by what the code needs — the full
language means the GC heap; a numeric/string kernel that fits the subset gains
universal portability and a hundreds-of-bytes binary from --no-gc — then pick
the packaging by the host: a component host gets --component, a plain
engine or JavaScript embedder gets a core module.
Two further flags are orthogonal to the shape, and covered in Cross-Cutting Flags at the end:
--optimizetree-shakes the module (a no-op on the GC--componentpath);--simdaccelerates the numeric vector kernels with nativev128instructions, on both value models.
Exporting Lisp Functions
By default a compiled module only exposes its entry point (_start). To make an
individual Lisp function callable directly from a host (wasmtime --invoke,
JavaScript, or another module), mark it with the rontolisp:wasm-export
directive, declaring the WASM-boundary types of its parameters and result:
rontolisp fact.lisp -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5
120
The directive itself is the same in every output shape; what changes per shape
is the host contract of the export — a raw core function on the core-module
shapes, a typed component-model export under --component. On the interpreter
and JVM backends the directive is a no-op (it just returns the named symbol), so
the same source runs on every backend.
The type designators and their boundary representations are:
| Designator | WASM boundary | Notes |
|---|---|---|
:int | i32 | 31-bit signed range (the internal i31ref) |
:long | i64 | --no-gc only; full 64-bit signed range, matching the non-GC backend's internal i64 |
:float | f64 | |
:bool | i32 | 0 is nil, any non-zero value is t |
:string | (ptr, len) | UTF-8 bytes in linear memory; a component-model string under --component |
:s-expr | (ptr, len) | s-expression text (any value except a function); GC value model only |
A side-effecting function can declare a void result by omitting :returns
(or giving it as nil, '() or :void); the wrapper then discards the Lisp
return value and has no WASM result. Likewise an omitted, nil or '()
:params means no arguments.
:as renames the export — useful when the host-facing API wants a name that is
not an idiomatic Lisp symbol, e.g. camelCase for JavaScript:
Limitations shared by every shape:
- Only a top-level
defuncan be exported, the declared parameter count must match its arity, and functions that take or return function values are out of scope. - The exported name defaults to the bare Lisp name (
fact) and can be renamed with:as; how arguments are written depends on the host (wasmtime --invoke fact module.wasm 5,instance.exports.fact(5), ...).
Export Modes at a Glance
The same directive compiles into four different host contracts depending on the
--no-gc / --component flags:
GC core module (default / --no-wasi) | GC --component | --no-gc core module | --no-gc --component | |
|---|---|---|---|---|
| Host requirements | wasm-GC engine (wasmtime -W gc, Node 22+, current browsers) | wasmtime 46+ (-W gc=y -W component-model-more-async-builtins=y) or a component host with wasm-GC + JSPI | any WebAssembly engine | any component-model host, no flags |
| Export shape | raw core function | typed component-model export (WAVE --invoke, jco) | raw core function | typed component-model export (WAVE --invoke, jco) |
| Scalars | :int/:float/:bool/void | :int/:float/:bool/void | + :long (i64) | + :long (s64) |
:string | manual (ptr,len) + __ronto_alloc | component-model string (canonical ABI) | manual (ptr,len) + __ronto_alloc | component-model string (canonical ABI) |
:s-expr | manual (ptr,len) | component-model string (printed text) | not supported | not supported |
| Function body may use | the full language | the full language | the non-GC subset | the non-GC subset |
| I/O inside the export | works (real WASI imports; traps under --no-wasi) | traps in a sync export; declare :async t | print only (one fd_write import) | print only (built-in WASI 0.2 stdio bridge) |
| Program top level | runs as _start | co-exists as wasi:cli/run | defun + directives only | defun + directives only |
| Per-call string memory | host-managed (__ronto_alloc) | freed by the canonical post-return | host-managed (__ronto_alloc + the arena API; automatic for scalar returns) | freed by the canonical post-return |
| Typical size | ~100 KB (~2 KB with --optimize) | ~110 KB | tens of bytes to a few KB | hundreds of bytes to a few KB |
The rest of this page details each shape: how its exports are called, what runs inside them, and what each host must provide.
The Default Output: a wasm-GC Core Module
The default output — no flags beyond -o file.wasm — is a WASI Preview 1
core module over the wasm-GC value model:
- wasm-GC — Integers are represented as
i31ref. Floating-point numbers are boxed in afloat_struct { f64 }. All values on the stack are typed as(ref eq). This is what supports the full language (cons cells, symbols, closures, hash tables,eval, ...), and why the module needs a wasm-GC capable runtime such as wasmtime 14+ (-W gc), Node 22+, or a current browser. - WASI Preview 1 — the module imports the eight
wasi_snapshot_preview1functions (fd_writefor stdout,random_get, clocks, environment, ...) and exposes the_startentry point, sowasmtime runexecutes the program's top level like a command.
An exported function is a raw core function: scalars
(:int/:float/:bool) cross as plain numbers, so wasmtime --invoke and
instance.exports.fact(5) work directly. The memory-backed :string and
:s-expr designators pass a (ptr, len) pair through the module's exported
memory, together with a __ronto_alloc(size) bump allocator the host uses to
stage argument bytes — that protocol needs a host that can read and write
memory (JavaScript, not wasmtime --invoke), and is walked through end to end
in the appendix. Instantiating
the module still needs the eight WASI imports satisfied; wasmtime run
provides them automatically, a browser host can supply no-op stubs for a
pure-compute function, or add --no-wasi to drop them
entirely.
Two behavioral notes on this value model:
- Parameter limit. A function (
defunorlambda) may take at most seven parameters (the interpreter and JVM backends have no such limit). A fixed-aritydefunpast the limit is bundled automatically: the compiler keeps the first six parameters, packs the rest into a list, and rewrites every direct call site to match — so wide library signatures compile unchanged. Taking such a function's value with#'name/symbol-functionis a compile error (only direct calls know the bundled shape), and alambdaor variadic function past the limit still errors — bundle those arguments into a list yourself. The rest list of a variadic function counts as one parameter, so a&restfunction may declare at most six required parameters while accepting any number of arguments at a direct call site. - Float printing shape. Floats of every magnitude print on WASM: the
integer part is exact up to 2⁶³, larger values fall back to an approximate
exponent form (
1.0E19), andInfinity,-InfinityandNaNprint as those words, like the other backends. One shape difference remains: from 10⁷ up to 2⁶³ WASM prints all the digits (1500000000000.0) where the interpreter and the JVM use exponent notation (1.5E12);rontolisp:json-stringifyinherits that shape difference.
Importing Host Functions
rontolisp:wasm-import is the reverse of wasm-export: it declares a function
the host provides and makes it callable from Lisp under the given name
exactly like a top-level defun — including #'name, funcall, mapcar and
eval. :from names the import module (default "env"), :as names the
field inside it (default: the Lisp name), and the type designators are the same
table as above:
In wasmtime, satisfy the imports by preloading another module that exports them
— here a host module that is itself written in Lisp, exporting its function
under the :as alias add:
$ cat host.lisp
(defun host-add (a b) (+ a b))
(rontolisp:wasm-export 'host-add :as "add" :params '(:int :int) :returns :int)
$ rontolisp host.lisp -o host.wasm --no-wasi
$ rontolisp main.lisp -o main.wasm --no-wasi
$ wasmtime run -W gc --preload host=host.wasm --invoke add10 main.wasm 32
42
In a browser (or Node) the import object is the module table — one key per
:from name, one property per :as name. This is also the escape hatch for
anything the WASM backend does not provide; for example it has no trigonometric
built-ins, so borrow JavaScript's:
const imports = { math: { sin: Math.sin, cos: Math.cos } };
const { instance } = await WebAssembly.instantiate(bytes, imports);
The WebGL triangle example is the hello world of this pattern: ten imported functions, no exports, and a colored triangle drawn entirely from Lisp. The WebGL cube example adds 3D: the perspective and rotation matrices are computed in Lisp every frame. The WebGL galaxy example is the same idea grown into a complete browser program: the entire WebGL pipeline is driven from Lisp — the GLSL shaders live in the Lisp source, and Lisp compiles, links, buffers and issues every draw call through 34 imported host functions, while JavaScript supplies only one-line bindings over a handle table.
Boundary details beyond the scalar types:
- A
:string/:s-exprargument reaches the host as a(ptr, len)pair into the module's exportedmemory(an:s-exprargument is printed to readable text first). - A
:stringresult must be written into linear memory by the host — reserve the buffer with the exported__ronto_alloc, then return the(ptr, len)pair (a two-element array in JavaScript). - An
:s-exprresult is parsed with the embedded reader, so the host can hand back a whole list structure as text.
Limitations:
- Default (wasm-GC) Preview 1 output only:
--componentand--no-gcreject the directive with an error. - On the interpreter and JVM backends the directive defines a stub that signals an error when called, so a shared source still loads everywhere, but actually calling an import needs the WASM host.
- Imported functions have the same 7-parameter arity limit as other functions.
- Instantiating the module requires every declared import to be provided:
wasmtime runneeds a--preload <module>=<file>.wasmper import module name, and a JavaScript host passes an import object.
No-WASI (Reactor) Mode
Add --no-wasi to emit a Preview 1 module that imports no WASI functions,
so a host can instantiate it with no import object at all — a
"reactor"/library module whose only surface is the exported Lisp functions:
rontolisp fact.lisp --no-wasi -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5 # => 120
A reactor is just as easy to drive from JavaScript: there is no import
object, so the host side is just "instantiate, then call the exports"
(WebAssembly.instantiate(bytes).then(({ instance }) => instance.exports.fact(5))).
A complete, copy-paste runnable Node + browser example is in the
appendix at the end of this page.
The eight WASI import slots are filled with internal trap stubs so every
function index stays fixed (no other codegen changes). This mode is for
pure-compute exports only: any I/O (print/read/open/getenv/time/
random, including a top-level form that prints) hits a stub and traps. It
is Preview 1 only — --no-wasi is ignored under --component.
Because the module is a reactor (not a WASI command), its top-level initializer
is exported as _initialize rather than _start. A host should call
_initialize once after instantiation to run top-level forms
(defvar/defparameter/setq globals that an exported function reads);
pure-compute reactors that hold no top-level state can skip it.
WASI 0.3 Component (--component)
Add --component to emit a WASI 0.3 (Preview 3) component instead of a
Preview 1 core module. The component prints through wasi:cli/stdout@0.3.0:
rontolisp hello.lisp --component -o hello.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y hello.wasm
3
In WASI 0.3 all byte I/O flows through the built-in component-model
stream<u8> / future<T> types and the async canonical ABI. rontolisp keeps
the same Preview 1 core module unchanged — it still imports the eight
wasi_snapshot_preview1 functions — and an adapter core module implements
them over WASI 0.3 (wasi:cli, wasi:filesystem, wasi:clocks,
wasi:random) using stream.new/stream.read/stream.write and
future.read. The component's wasi:cli/run@0.3.0 export (an async func) is
lifted as a stackful async export, so the synchronous stream/future
built-ins block cooperatively and the adapter stays straight-line code. The
async canonical ABI and the stackful lift are enabled by default in wasmtime
46+; only the synchronous stream/future built-ins are still feature-gated,
hence -W component-model-more-async-builtins=y (plus -W gc=y for the
wasm-GC core).
The wasmtime invocation does not select the output kind. wasmtime run is
wasmtime's default subcommand and auto-detects a core module vs a component, so
wasmtime run -W gc runs a Preview 1 hello.wasm just as well. Only the
--component compile flag decides whether a Preview 1 core module or a WASI
0.3 component is produced. (The practical difference shows up on a
component-only runtime, which runs the component but not the Preview 1 core
module.)
What works inside a component, and what each feature needs at run time:
print/stdout, stdin (read, 0-argumentread-line, overwasi:cli/stdin@0.3.0), and file I/O (open,close,write-line, streamread-line,load,with-open-file) all work. File access requires--dir(paths resolve against the first preopened directory):
cat > fileio.lisp <<'EOF'
(with-open-file (out "greeting.txt" :direction :output)
(write-line "hello" out))
(with-open-file (in "greeting.txt")
(print (read-line in)))
EOF
rontolisp fileio.lisp --component -o fileio.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --dir . fileio.wasm
# "hello"
randomdraws real entropy fromwasi:random@0.3.0(Preview 1 uses the host'srandom_get), so(random N)differs each run.get-universal-time/get-internal-real-time/get-internal-run-timereadwasi:clocks@0.3.0(system-clock/monotonic-clock), andgetenvreadswasi:cli/environment@0.3.0.- Outgoing HTTP (
rontolisp:fetchwith therontolisp:await/rontolisp:then/rontolisp:promiseppromise operations) works in component mode, including true asynchrony:fetchsends the request and returns a promise (wrapping the in-flightwasi:httpresponse handle) immediately, so several requests can overlap beforeawaitblocks on each. The promise operations themselves compile in every mode; onlyfetchis component-only. It is a hybrid: the base I/O stays WASI 0.3 while fetch itself importswasi:http@0.2+wasi:io@0.2(asyncwasi:http@0.3does not exist upstream yet). Run a fetch component with-S http=yin addition to the async flags. Non-fetch components do not importwasi:http, so they do not need-S http. - TCP sockets (
rontolisp:tcp-connect/tcp-listen/tcp-accept/tcp-local-port) work in component mode overwasi:sockets@0.3.0(natively WASI 0.3 — no 0.2 hybrid). A socket is a bidirectional stream handle, soread-line/write-line/read-byte/write-byte/closework on it directly. Run a socket component with-S tcp=y -S inherit-network=yin addition to the async flags; without them the component still starts but every socket operation fails and yieldsnil. Hosts must be IPv4 literals (no hostname resolution yet), androntolisp:fetchcannot be combined with the tcp functions in one component yet. - The compiled Lisp otherwise behaves identically to the Preview 1 output for
the supported features. Serving incoming HTTP (
rontolisp:http-handler) also compiles to a component, but a different kind (wasi:http/incoming-handler) run underwasmtime serve— see the HTTP handler guide.
Component-model Function Exports (wasm-export)
Under --component, a rontolisp:wasm-export
becomes a typed component-model export, callable through the canonical ABI
with WAVE syntax (wasmtime run --invoke 'name(args)', no experimental
warning) — and it co-exists with the wasi:cli/run command entry, so the same
component still runs as a command:
rontolisp sumsq.lisp --component -o sumsq.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'sumsquared(2, 3)' sumsq.wasm
# 25 (the export's return value, rendered by wasmtime)
wasmtime run -W gc=y -W component-model-more-async-builtins=y sumsq.wasm
# 400 (the ordinary run entry executes the top-level program)
The two commands print different things: --invoke calls only the named
export — the top-level program (the wasi:cli/run entry) does not run — and
the 25 is wasmtime rendering the export's return value in WAVE syntax, not
output from print. The plain run executes the top-level program instead,
so the 400 is the output of (print (sumsquared 10 10)).
The typed signature (:int → s32, :float → f64, :bool → bool,
:string → string, :s-expr → string carrying the printed s-expression
text, omitted :returns → no result) is visible to any component host, and
:as renames the component export just like the core one.
A :string boundary crosses as a real component-model string — no manual
pointer handling on either side. The host lowers the argument bytes into linear
memory and reads the result back out through the canonical ABI, and the module
frees the per-call allocations afterwards (a canonical post-return function
pops the bump allocator), so a resident instance stays flat across repeated
calls:
rontolisp greet.lisp --component -o greet.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'greet("世界")' greet.wasm
# "Hello, 世界"
By default an export is lifted synchronously and must be pure-compute: I/O
inside it (print, read, rontolisp:fetch, file access) traps at runtime
with "cannot block a synchronous task". Declare the export async with
:async t to lift it against an async function type instead — the same
stackful-async shape as the run entry — and I/O inside it works.
wasmtime --invoke calls an async export exactly the same way:
rontolisp status.lisp --component -o status.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y -S http=y \
--invoke 'fetch-status("https://httpbin.org/status/204")' status.wasm
# "fetching"
# 204
In the component's WIT-level contract an :async t export is an async func
(for example, jco types it as a Promise-returning function, while a sync export
stays a plain function). Sync and async exports mix freely in one component,
:async composes with every boundary type including :string/:s-expr, and a
program without :async exports produces byte-identical output.
Current limitations of component exports:
- A sync (default) export is pure-compute only: I/O inside it traps at
runtime with "cannot block a synchronous task". Opt into
:async twhen the export prints, fetches, or otherwise does I/O; keep pure-compute exports sync. :asyncis meaningful only here: Preview 1 /--no-wasicore exports ignore it (the host provides I/O directly there), and--no-gc --componentrejects it (the compact reactor component has no async adapter).- jco (1.25.2) transpiles an
:async texport and types it as async, but cannot call it yet — its generated driver assumes callback-style async tasks, and stackful async exports are not implemented upstream (the same gap as calling the transpiledrun).wasmtime run --invokeis the verified path for async exports; sync exports work on both. - The export name must be a lower-kebab-case component-model name
(
sum-squared); for a Lisp name outside that grammar the compiler asks you to rename it with:as. - Invoking an export does not run the program's top level first, so an export
that reads a
defvar/defparameterglobal would see it uninitialized (this matches the Preview 1--invokebehavior).
For a pure-compute export kit, the compact
--no-gc --component variant
emits the same typed exports (plus :long → s64, minus :s-expr) in a
component of a few hundred bytes that needs no wasmtime flags at all.
Non-GC Output (--no-gc)
Every GC-value-model output above — even an optimized reactor — still needs a
wasm-GC capable runtime, because every value is a GC heap type (i31ref,
the float struct, (ref eq)). Add --no-gc to emit a plain MVP module
instead: no rec group, no struct/array/i31 type, no eqref and no import
(a plain linear memory is added only when the program uses strings — see
below — and the single fd_write import only when it
prints). A print-free module instantiates
with no import object and runs on any MVP-class runtime with no -W gc:
rontolisp fact.lisp --no-gc --optimize -o fact.wasm
wasmtime run --invoke fact fact.wasm 5 # => 120, no -W gc needed
It achieves this by lowering each value directly onto an unboxed wasm scalar,
plus a small linear-memory representation for strings — so the eligible subset
is a restriction of the language, not a different one. The program shape is
also restricted: the top level may contain only defuns and
rontolisp:wasm-export directives (a pure-compute reactor — there is no
_start), and the boundary designators are :int, :long, :float, :bool,
:string (and :void/omitted); :s-expr is not supported — it would need
the cons/reader/printer runtime this backend deliberately omits.
Numeric vector kernels (the vec: package)
work under --no-gc too, lowered to plain scalar loops by default — so a
vector program keeps the "runs on any MVP runtime" property above. Add
--simd to lower those kernels to native
WebAssembly SIMD (v128) instead, which then needs a runtime with the SIMD
proposal (on by default in wasmtime).
Eligible subset
A function is eligible only if its entire transitive call graph stays inside this subset:
- numbers and booleans: arithmetic (
+ - * / mod rem 1+ 1- abs min max sqrt), the integer bitwise operators (logand logior logxor lognot ash), comparison and predicates (= < <= > >= not zerop plusp minusp evenp oddp); - control and binding:
if/when/unless/cond/progn/let/let*, recursion and calls to other eligible functions; - iteration and local mutation:
dotimes/do/do*and the underlyingwhile/setq/return, with a let/do-bound variable freely reassigned;loopis eligible only for its non-consing clauses (numericfor,sum/count/maximize/minimize,repeat/while/until/do/return) — itscollect/append/nconcandfor ... in/onclauses allocate lists and are not; - float/int conversions:
float truncate floor ceiling round; - strings and characters: string literals, character literals,
(concatenate 'string ...),length,subseq,string=,char,char-code/code-char,char=andprinc-to-string(of integers, floats and strings). There is no separate character type: a character is represented by its code point, so the portable idioms(char= (char s i) #\x)and(char-code (char s i))behave exactly like the other backends, while a bare(char s i)crossing an:intboundary shows the code; - printing:
print,princandterpri(without the optional stream argument) — see below; - memory reclamation:
rontolisp:with-arena.
Anything else that would allocate a heap object (cons/list, symbols, vectors,
hash tables, eval/apply, I/O, dolist/list iteration, a free variable or
assignment to a global, a lambda-list keyword such as &optional/&rest/
&key — the rest list is a cons) makes the function ineligible. Rather than
miscompile silently, that is a compile error naming the offending
operation, so the boundary stays explicit.
Numeric model
Each value's wasm type is chosen by static type inference: integers use i64,
floats use f64. Types are inferred with a fixpoint over the call graph seeded
by the export boundary designators, and where an integer and a float meet
(e.g. (* 3.14 n)) the integer is promoted to f64. Using i64 makes integer
arithmetic exact to 2^63 — far wider than both the GC backend's i31 fixnums
and what an all-f64 lowering (exact only to 2^53) could offer; for example
a*a - (a-1)*(a+1) stays exactly 1 even when the intermediates exceed 2^53.
Inference also widens automatically: a let/do-bound variable takes the join
of its initializer and every value assigned to it, so an integer accumulator
summed with floats becomes an f64:
Under --no-gc this infers acc (and the return value) as f64 while the
loop counter i stays i64.
There is no rational type, so two things differ from full Common Lisp and from
the GC backend: / is floating-point division (no 1/3 ratios), and a value
is false in a boolean context exactly when it is zero (Common Lisp treats only
nil as false). The boundary designators stay host-width — :int/:bool
cross as a 32-bit i32 (as in the GC backend), so a returned value outside the
32-bit range wraps; the wide i64 range applies only to the internal
computation. When a parameter or result can exceed the 32-bit range, declare it
:long — it crosses the boundary as i64 with no wrap/extend (:long is
--no-gc-only; the GC backend rejects it, its integers being i31ref). For
the numeric kernels this mode targets (factorials, math/finance functions,
validators) the results match the interpreter and the GC backend.
Strings
A string is an i32 pointer to a [length][bytes] header in linear memory,
and (concatenate 'string ...) bump-allocates a fresh buffer — so building up
a string is just an accumulator loop:
Slicing and inspection work on the same representation: length reads the
header, subseq copies a slice into a fresh buffer, string= compares content
byte-wise, char indexes a byte, and princ-to-string renders an integer —
enough for routing/parsing kernels, not just accumulation:
A module that uses strings gains a (growable) linear memory, and exports that
memory plus a __ronto_alloc(size) bump allocator alongside your functions.
A :string parameter arrives as a (ptr, len) pair the host writes into
memory, and a :string result is returned the same way — so a string-valued
export needs a host that can read/write the exported memory (JavaScript, a
small Node script, the browser playground) rather than just
wasmtime --invoke. The appendix walks through the
JS side, and --no-gc --component
removes the manual protocol entirely.
This is what lets the ASCII-art Mandelbrot renderer run with no wasm-GC:
examples/console/mandelbrot-nogc.lisp
keeps the floating-point escape-time loop but returns the rendered grid as one
string instead of printing it:
$ rontolisp examples/console/mandelbrot-nogc.lisp --no-gc --optimize -o mandelbrot.wasm
$ node -e '(async () => {
const ex = (await WebAssembly.instantiate(
require("fs").readFileSync("mandelbrot.wasm"), {})).instance.exports;
const [p, n] = ex.mandelbrot(-2.5, 1.0, -1.2, 1.2, 70, 30, 30);
process.stdout.write(Buffer.from(new Uint8Array(ex.memory.buffer, p, n)).toString());
})()'
Printing (print / princ / terpri)
An exported function can print: print (readable form plus a trailing newline,
so strings come out quoted), princ (display form, no newline) and terpri (a
newline) work inside the eligible subset, with output byte-identical to the
interpreter:
$ cat show.lisp
(defun show (n)
(print n)
(print (* 1.5 n))
(print "done"))
(rontolisp:wasm-export 'show :params '(:int) :returns :void)
$ rontolisp show.lisp --no-gc -o show.wasm
$ wasmtime run --invoke show show.wasm 4
4
6.0
"done"
Floats print through the same digit-extraction printer as the GC backend,
including the IEEE edges (NaN, Infinity/-Infinity, -0.0; a magnitude ≥
2^63 uses the WASM backends' E-notation shape). Each print of a number
renders its text into a transient string that is reclaimed immediately, so a
print loop does not grow the heap.
Two things to know:
- A printing module has one import.
print/princ/terpriwrite through a singlewasi_snapshot_preview1.fd_writeimport — added only when the program prints, so a print-free module keeps zero imports and its exact bytes. Any WASI Preview 1 host providesfd_writefor free (wasmtime run, Node's built-innode:wasimodule), but a printing module no longer instantiates with an empty{}import object the way the Mandelbrot snippet does — a raw JavaScript embedder must supply{ wasi_snapshot_preview1: { fd_write } }(or usenode:wasi). - Booleans print by literal only. The value model has no runtime boolean
type:
(print t)/(print nil)printt/nil, but a computed boolean such as(print (> a b))prints its0/1integer. The optional stream argument and printing a packed float array are compile errors.
Reclaiming memory (the arena API)
__ronto_alloc is a bump allocator that never frees, so a resident host —
one that keeps a single instance alive and calls it in a loop, allocating a
fresh input buffer each time — grows its linear memory without bound. Two
mechanisms keep it flat:
- Automatic, for scalar returns. When an export returns a non-memory scalar
(
:int/:long/:float/:bool/:void), its wrapper snapshots the heap top on entry and restores it on exit, so everything the call allocates (the internal copy of a:stringargument, plus anyconcatenate/subseq/princ-to-stringscratch) is reclaimed on return. Nothing to do host-side. - Manual, for the host's own buffer. The host allocates its input buffer before the call, so it sits below the wrapper's auto-reset mark and is left live. To reclaim it too, the string-using module also exports a matched pair over the same heap pointer:
| export | signature | meaning |
|---|---|---|
__ronto_alloc_mark | () -> i32 | snapshot the current bump-heap top |
__ronto_alloc_reset | (i32 mark) -> () | restore the top to a saved mark |
Snapshot before allocating the input, restore after reading the result, and a resident instance stays perfectly flat no matter how many times it is called:
node -e '(async () => {
const ex = (await WebAssembly.instantiate(
require("fs").readFileSync("count_vowels.wasm"), {})).instance.exports;
const enc = new TextEncoder();
const countVowels = (s) => {
const b = enc.encode(s);
const mark = ex.__ronto_alloc_mark(); // snapshot BEFORE allocating input
const ptr = ex.__ronto_alloc(b.length);
new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
const n = ex.count_vowels(ptr, b.length); // scalar result read out here
ex.__ronto_alloc_reset(mark); // pop the input + wrapper scratch
return n;
};
const before = ex.memory.buffer.byteLength;
for (let i = 0; i < 100000; i++) countVowels("Hello, World! " + i);
console.log(before, "->", ex.memory.buffer.byteLength); // 65536 -> 65536 (flat)
})()'
The arena is a manual stack, not a garbage collector, so two rules apply:
- Only reset to a mark taken before everything still live — popping to a mark taken after data you still need frees that data.
- A
:string-returning export does not auto-reset (its result is a live heap pointer). Read the returned bytes out of memory before calling__ronto_alloc_reset— resetting first frees the string and the next allocation overwrites it.
The count-vowels example
walks through this recipe with both a Node and an Endive
(Java) host.
Reclaiming from Lisp (rontolisp:with-arena)
Both mechanisms above fire at the export boundary — nothing is freed
within one call. A loop that allocates each iteration
(concatenate 'string builds a fresh buffer, vec:zeros/vec:ones a fresh
vector) therefore grows the heap for the duration of the call.
rontolisp:with-arena names
that reclamation boundary in the source: it snapshots the bump heap pointer,
runs its body, and pops everything the body allocated — keeping only the body's
own value (a string or packed float array result is copied down to the snapshot
point):
With the arena, a hundred thousand iterations stay within the initial linear
memory; without it, the same loop grows by one vector per iteration. The escape
contract is the same as __ronto_alloc_reset's: nothing allocated inside the
body may be reachable after it, except the body's own value. On the
interpreter, the JVM backend and the default (wasm-GC) output, with-arena is
observationally a plain progn — a real garbage collector already reclaims —
so the same source runs on every backend.
Compact Component Output (--no-gc --component)
Add --component to wrap the same MVP core module as a WASM component
whose exports become typed component-model exports, callable through the
canonical ABI with WAVE syntax. A print-free core module has zero imports, so
the wrap needs no WASI adapter, no shared-memory module and no wasm-GC — the
whole component stays in the hundreds of bytes for a small program and runs
with no wasmtime flags at all:
rontolisp fact.lisp --no-gc --component -o fact.wasm
wasmtime run --invoke 'fact(5)' fact.wasm
# 120
The typed WIT signature maps :int → s32, :long → s64, :float → f64,
:bool → bool, :string → string, and an omitted :returns → no result.
The component also transpiles with jco (jco transpile, where :long surfaces
as a JavaScript BigInt) and runs on any component-model host, with no wasm-GC
support required.
:long is valid here, unlike the GC component path — use it when a value can
exceed the 32-bit range, matching the backend's internal i64 arithmetic:
rontolisp cube.lisp --no-gc --component -o cube.wasm
wasmtime run --invoke 'cube(2000000)' cube.wasm
# 8000000000000000000
A :string boundary crosses as a real component-model string — no manual
pointer handling on either side. The host lowers the argument bytes into the
module's own memory and reads the result back out through the canonical ABI,
and the module frees every per-call allocation afterwards (a canonical
post-return function pops the bump allocator to its base), so a resident
instance stays flat across repeated calls:
rontolisp greet.lisp --no-gc --component -o greet.wasm
wasmtime run --invoke 'greet("world")' greet.wasm
# "Hello, world"
Printing works here too: a program that
prints gets a built-in print micro-adapter — three tiny fixed core modules
that implement the core's single fd_write import over WASI 0.2 stdio
(wasi:cli/stdout plus wasi:io/streams' synchronous
blocking-write-and-flush), wired in only when the program prints. The exports
stay ordinary sync lifts, the zero-flag property is kept (hosts provide 0.2
stdio by default), and the print output is byte-identical to the interpreter —
with the earlier show.lisp:
rontolisp show.lisp --no-gc --component -o show.wasm
wasmtime run --invoke 'show(4)' show.wasm
# 4
# 6.0
# "done"
# ()
Trade-offs against the plain --no-gc output, and current limits:
- A component needs a component-model-capable host; the raw core module runs on
any WebAssembly engine through the plain embedding API. Both outputs stay
available — pick per host, and note the component is not the default for
--no-gc. (Without--component, a:stringcrosses as the manual(ptr,len)core ABI instead.) - The component is a pure reactor: there is no
wasi:cli/runentry (nothing runs at the top level). Printing inside an export works through the micro-adapter above; every other I/O stays outside the--no-gcsubset as usual.:async tis rejected (there is no async adapter to suspend on). - The export name must be a lower-kebab-case component-model name; for a Lisp
name outside that grammar the compiler asks you to rename it with
:as. --optimizecomposes: the core module is tree-shaken before the wrap.
Cross-Cutting Flags
Optimize (Tree Shaking)
By default a compiled module embeds the entire runtime (printer, rational,
string, reader and eval helpers, the WASI import slots, …) regardless of what
the program actually uses, because function indices are held fixed. Add
--optimize to drop every function unreachable from the module's roots (its
exports and the _start/_initialize entry) and renumber the survivors.
Unused WASI imports are removed too, so a pure-compute reactor module shrinks
to a handful of functions:
rontolisp fact.lisp --no-wasi --optimize -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5 # => 120, from a ~2 KB module
For the fact example the module drops from ~100 KB to under 2 KB.
--optimize is opt-in and behavior-preserving: it walks the call graph from
the actual call instructions, so anything reachable (including code an
embedded eval/load dispatches to) is kept. On the GC --component path
it is a no-op (the WASI 0.3 adapter relies on the core's fixed import/index
layout, so the component is emitted unchanged); under
--no-gc --component it works
— the core module is shaken before the wrap. The same flag also
dead-code-eliminates the JVM output.
Independently of --optimize (and on every output mode, --component
included), compilation always tree-shakes the bundled Lisp-source libraries
(linalg:, vec:, JSON, URL, equalp/string<): a library function your
program never mentions -- by name anywhere in the source, including quoted
symbols and string literals -- is not compiled into the module. The one
consequence: a library function whose name is only assembled at runtime from
computed strings and called through eval/apply signals the usual
"undefined function" error. Compile with --no-prune (or --dynamic) to keep
every library definition in that case.
SIMD Acceleration (--simd)
--simd is the one acceleration switch shared by every backend: it lowers the
vectorizable vec: and linalg: kernels to
real vector instructions. On WASM it is orthogonal to the value model:
- wasm-GC +
--simdlowers the kernels to native fixed-width SIMD (f64x2/f32x4) over GC-managed lane-group arrays — packed float arrays stay ordinary GC objects, and memory behaves exactly as without the flag. Composes with--componentand--optimize; run as usual withwasmtime run -W gc(wasmtime enables the SIMD proposal by default). --no-gc+--simdlowers the same kernels tov128over the packed linear-memory blocks. Without--simd,--no-gcemits plain scalar loops instead — a v128-free MVP module that also runs on a runtime lacking the SIMD proposal.
The full story — which kernels vectorize, precision rules for single-float
reductions, measured effects, and the linalg interception — lives in the
SIMD acceleration guide.
Appendix: Calling a Module from JavaScript
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 (runs on any engine) and --optimize (drops
everything unreachable from the exports — here the whole module is ~200 bytes):
rontolisp mathkit.lisp --no-gc --optimize -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.
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 above). A greeting builder is enough to
show the protocol:
rontolisp greetkit.lisp --no-gc --optimize -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 --optimize -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(...)).