(rontolisp) docs

WASM Host Boundary (wasm-export / wasm-import)

Two complementary directives declare what crosses the module/host boundary in rontolisp's own type designators. Both work in every WASM output shape (the same source runs on every backend — the directives are no-ops or defun stubs on the interpreter and the JVM).

For the typed WIT-driven boundary, see the WIT contracts guide.

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:

DesignatorWASM boundaryNotes
:inti32full 32-bit signed range
:longi64full 64-bit signed range on every backend
:floatf64
:booli320 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
:bytes(ptr, len) argument / (ptr, cap) -> len resultan (unsigned-byte 8) vector as raw bytes — no UTF-8 in either direction; GC core-module shapes only (not --component, not --no-gc)

:string carries a value (decoded, allocated per call); :bytes carries a transfer: the caller passes the buffer, the read(2) shape. A :bytes result appends a (ptr, cap) pair to the export's parameters — the host reserves cap bytes (e.g. with __ronto_alloc) and the wrapper copies at most cap bytes there — and the single i32 result is the vector's full length, so an undersized buffer is a retry, not a truncation. No per-call allocation is spent on the transfer, which is what keeps a chunked pull loop's memory flat.

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 defun can 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), ...).
  • The exported function may be a rontolisp:async-defun: the boundary resolves the future it answers, so the host receives the declared type and never a future.

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 requirementswasm-GC engine (wasmtime -W gc, Node 22+, current browsers)wasmtime 46+ (-W gc=y) or a component host with wasm-GC + JSPI (a browser via jco loads and computes, but cannot print yet)any WebAssembly engineany component-model host, no flags — including a browser via jco, with no dependencies at all
Export shaperaw core functiontyped component-model export (WAVE --invoke, jco)raw core functiontyped component-model export (WAVE --invoke, jco)
Scalars:int/:long/:float/:bool/void:int/:long/:float/:bool/void:int/:long/:float/:bool/void:int/:long/:float/:bool/void
:stringmanual (ptr,len) + __ronto_alloccomponent-model string (canonical ABI)manual (ptr,len) + __ronto_alloccomponent-model string (canonical ABI)
:s-exprmanual (ptr,len)component-model string (printed text)not supportednot supported
:bytesmanual (ptr,len) / caller-buffered resultnot supported (no list<u8> lift yet)not supportednot supported
Function body may usethe full languagethe full languagethe non-GC subsetthe non-GC subset
I/O inside the exportworks (real WASI imports; under --no-wasi output is discarded, random runs on a built-in generator, getenv/file lookups answer nothing, the clock is the one the host wrote through __ronto_set_time and input traps)usually works even in a sync export; :async t removes the residual trap riskprint only (one fd_write import)print only (built-in WASI 0.3 stdout bridge; the exports become async lifts)
Program top levelruns as _startco-exists as wasi:cli/rundefun + directives onlydefun + directives only
Per-call string memoryhost-managed (__ronto_alloc + the arena API; the Lisp side is the engine's)freed by the canonical post-returnhost-managed (__ronto_alloc + the arena API; automatic for scalar returns)freed by the canonical post-return
Typical size~2 KB (tree-shaken; ~100 KB at --optimize=off)~110 KBtens of bytes to a few KBhundreds of bytes to a few KB

Each shape's own guide details how its exports are called, what runs inside them, and what each host must provide: wasm-GC core module, WASI 0.3 component, --no-gc output and its compact component wrap.

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 32 imported host functions, while JavaScript supplies only one-line bindings over a handle table -- generated from the WIT that declares the boundary.

Boundary details beyond the scalar types:

  • A :string/:s-expr argument reaches the host as a (ptr, len) pair into the module's exported memory (an :s-expr argument is printed to readable text first).
  • A :string result 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-expr result is parsed with the embedded reader, so the host can hand back a whole list structure as text.
  • A :bytes argument is an (unsigned-byte 8) vector staged as a raw (ptr, len) pair — no UTF-8 encode, so arbitrary binary crosses exactly.
  • A :bytes result is caller-buffered: the Lisp signature gains one trailing parameter, the (unsigned-byte 8) vector to receive into, and the host is called with a trailing (ptr, cap) pair — write up to cap bytes at ptr, return the full length n. The call answers n (an n above the buffer's length means "retry with a bigger buffer"), and the wrapper's staging is popped on return, so a pull loop over one reused buffer keeps linear memory flat.
  • An asynchronous host function — a WebAssembly.Suspending-wrapped import under JSPI — is declared with :async t: the call then returns a future that rontolisp:await resolves, the build prints the host's obligations (Suspending on the import, promising on the exports that can reach it, serialised calls — a re-entered export refuses with a trap instead of corrupting both calls, unless the module was compiled --reentrant), and a call reachable from a top-level form of a --no-wasi module is a compile error (_initialize cannot suspend). The reference page has the full contract.

Limitations:

  • Default (wasm-GC) Preview 1 output only: --component and --no-gc reject 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 10-parameter arity limit as other functions under the wasm-GC value model.
  • Instantiating the module requires every declared import to be provided: wasmtime run needs a --preload <module>=<file>.wasm per import module name, and a JavaScript host passes an import object.

Choosing the Body Boundary (--host-boundary)

An HTTP reactor — a --no-wasi module whose entry point a host calls, which is what clack:clackup and rontolisp:http-handler compile to there — speaks one JSON envelope in each direction. What --host-boundary decides is whether a body rides inside that envelope or crosses beside it, and it changes what the module imports, so it is a flag of its own rather than a value on --emit-js-glue.

envelope (default)streaming
Request bodythe envelope's "body" keyenv.readRequestBody(ptr, cap) -> i32, a chunk per call
Response bodythe head's "body" keyenv.writeResponseBody(ptr, len), a chunk per call
rontolisp:fetch reply body (--host-fetch)the reply head's "body" keyenv.readResponseBody(ptr, cap) -> i32
Host-side statenonea cursor per reading import
Binary bodydoes NOT survive — ff fe 41 arrives as ef bf bd ef bf bd 41crosses exactly
Large bodylinear memory grows with itstays flat
Streamed upstream replybuffered, then forwardedforwarded chunk at a time
Generated host halfinstantiate, defaultHost() and worker(module) — the same on both
$ rontolisp worker.lisp -o worker.wasm --no-wasi --host-fetch
$ wasm-tools print worker.wasm | grep -oE '\(import "[^"]+" "[^"]+"'
(import "env" "fetch"

envelope is the default, and it is the one to want. A body that is a document — a Worker that reads one JSON request and answers one JSON reply — pays a copy nobody can measure and gets back a boundary with no host-side state in it, which is where the bugs on this surface have all been. Ask for --host-boundary=streaming when one of these is true:

  • a body is BINARY — an image, a file, protobuf, anything already compressed. The envelope carries a body as JSON text, so bytes that are not valid UTF-8 do not survive: ff fe 41 arrives as the seven bytes ef bf bd ef bf bd 41, two replacement characters where two octets were, with the content-length beside it still saying three. Nothing reports it.
  • a body is LARGE — the envelope puts it in linear memory whole, so memory grows with the body; the split reads through one reused buffer and stays flat however big it gets.
  • you are relaying an upstream reply — the split forwards it a chunk at a time instead of holding the whole thing first.

Neither shape is a subset of the other, and the module sizes land within about 1% of each other either way round, so this is not a size decision either. It is not an ergonomics decision either: --emit-js-glue writes the host half of both, so the JavaScript is three lines whichever you pick.

The default moved here, and a rebuild is how you feel it. Before this, every --no-wasi reactor took the bodies out of the envelope; a module rebuilt without the flag now keeps them in it, which is a real regression for the three cases above and nothing at all for everything else. Add --host-boundary=streaming and the module is byte-for-byte what it was.

--host-boundary needs --no-wasi and a .wasm output, without --component or --no-gc: those two are in band already (a component's host functions cross the canonical ABI, and --no-gc imports nothing at all), and so is a plain WASI command module, whose host is wasmtime run and satisfies no env.* import. A hand-written reactor — one that spells out its own envelope adapter instead of going through clack:clackup — follows the build with the rontolisp-body-imports reader feature, which is present exactly where those imports are:

Generating the Host Glue (--emit-js-glue)

Everything above is derived from a declaration, so the JavaScript half can be too. --emit-js-glue writes it next to the module (out.wasm -> out.js): the import object, the (ptr, len) staging in both directions, the __ronto_alloc bracket around a call, the WebAssembly.Suspending wrappers, the WebAssembly.promising entry for exactly the exports the build lists, and the one-call-at-a-time queue a module that can suspend needs.

$ rontolisp worker.lisp -o worker.wasm --no-wasi --host-fetch --emit-js-glue
$ ls worker.*
worker.js  worker.lisp  worker.wasm

The generated file asks for the one thing a declaration cannot state: what each host function does. host is a plain function per import, keyed by import module and field, taking and answering ordinary JavaScript values — never a (ptr, len) pair:

import { instantiate, suspending } from "./worker.js";

const lisp = instantiate(module, {
  env: {
    fetch: suspending(async (request) => hostFetch(request)),
    readResponseBody: suspending(async () => nextChunk()),
    readRequestBody: () => take(requestBody),
    writeResponseBody: (chunk) => chunks.push(chunk),
  },
});
const reply = await lisp.handleRequest(head);

A chunk source must eventually answer null, or the module pulls the same octets forever — take() above hands the body over once and then reports the end. The glue holds whatever did not fit and drops it at the next call into the module; a host whose source moves inside one call (a new upstream reply, say) drops it with lisp.drop("env.readResponseBody"), since only that side knows.

suspending() is how a host says which of its entries answer a promise, and it is per entry because the wrapper is not free: an import that answers synchronously through one still parks the stack and returns to the event loop. Mark one and the file switches into its JSPI shape — the marked imports are wrapped, the entry points the build listed are entered through promising, and every call rides one promise chain. Mark none and the same file drives a synchronous host, where an entry point answers a value rather than a promise. A callback that answers a promise without being marked is reported by name rather than handing the module a Promise where an i32 was due.

Host state belonging to ONE call — what the module pulls during it, what the call leaves behind — is set inside that same critical section, because a suspended call returns to the event loop and the next request would otherwise move it:

const reply = await lisp.serially(async (entry) => {
  requestBody = bytes;
  chunks = [];
  return entry.handleRequest(head);
});

An import a declaration alone would miss is written too: under --host-random the entropy source is implemented rather than asked for, since preview1 fixes what random_get(buf, len) does. And a :bytes result is answered with chunks (null ends them), never with the module's buffer: the generated cursor keeps whatever did not fit, so which source the chunks come from — a ReadableStream, a Uint8Array — is all a host is left to decide.

Where the transport already fixed a host function, the file writes that too. Two halves of a reactor's boundary are not the program's choice at all, so the generated file exports them: defaultHost(), the env.fetch half --host-fetch fixes in both directions, and worker(module), which maps a Request onto the envelope and a Response off it. That holds on either boundary: where a body leaves the envelope, the reader it comes from is the Request worker() is already holding and the Response it is already building, so the body imports are written too. A Worker is then three lines:

import module from "./worker.wasm";
import { worker } from "./worker.js";

export default worker(module);

Both are defaults, not replacements. worker(module, options) takes host — import entries laid over the derived ones one at a time — and remoteAddr, a (request, env, ctx) => string for the envelope's optional client address, which is the one thing a runtime-neutral file may not guess (on Cloudflare it is (r) => r.headers.get("cf-connecting-ip")). What is NOT written is an import the program declared itself: instantiate still names it, and the sketch at the top of the generated file then says worker(module, { host }) instead.

The flag needs --no-wasi and a .wasm output: a component is instantiated through its own bindings generator, and a --no-gc module imports nothing, so new WebAssembly.Instance(module, {}) is already the whole of its glue. Nine worked examples on both boundaries — every reactor under examples/cloudflare-workers but one: src/worker.js is generated and checked in, and src/index.js is the three lines above. httpbin is the exception, and says so: it declares its rontolisp:wasm-export by hand, and only the synthesized bridge is recognised as the envelope's own entry point, so no worker() is written for it and its host stays hand-written.

Overlapping Calls (--reentrant)

A module that can suspend refuses a second call by default: nothing in it owns its state per call, so every export wrapper carries a re-entry guard and the build's obligation line says serialise calls. That is correct, and it costs the whole width of an I/O-bound workload — eight concurrent upstream round trips through one serialised instance take eight round trips. One instance per in-flight call avoids the queue but pays an instantiation per call and a GC heap per instance.

--reentrant is the opt-in that makes overlap sound on one instance: the module then owns its per-call state, the guard is dropped, and a JSPI host may start a call while another is parked. What overlaps is the parked time — one stack still runs at a time — so this buys I/O overlap, never CPU parallelism.

What moves, and what a host owes for it:

  • Every dynamically bound special variable lives in a per-call task record instead of the shared module global, so two overlapped calls binding the same variable each read their own binding back.
  • Linear-memory staging that must survive a park moves off the scratch stack into recycled park blocks (__ronto_park_alloc / __ronto_park_free, both exported). Three rules follow: a :string/:s-expr export result's (ptr, len) is a park block the reader frees after decoding; a :string/:s-expr import result must be written into a park block, which the module frees; and a :bytes receive buffer a host passes into an export must be a park block too.
  • An arena bracket (__ronto_alloc_mark / __ronto_alloc_reset) around an entry call is popped synchronously the moment the call starts — the arguments are consumed at entry — and the reset never goes below a live park block.

--emit-js-glue writes all of this and drops the queue, so a generated host needs nothing by hand; the build's obligation lines state the same rules for a hand-written one.

The flag requires a program that can suspend (an :async t import, or --host-fetch with rontolisp:fetch used) and a --no-wasi core module. It composes with --host-boundary=streaming: under --reentrant every body import leads with an :int id — the request envelope names its call with a "call-id" key, a fetch reply names its body with "body-id" — so each pull and push says what it belongs to instead of sharing one host-side cursor. The generated glue mints the ids and keys its per-call state by them; a body import declared without the leading id is refused under the flag. It cannot be combined with --dynamic. Reach for it when a workload is I/O-bound and cannot afford an instance per request: measured on the envelope Worker shape, eight concurrent 100 ms upstream round trips answer in about 125 ms on one instance, against about 800 ms serialised.