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 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 nine
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. Those built-ins are the asynchronous (non-blocking)
variants: when one reports BLOCKED, the task parks on a blocking
waitable-set.wait until the completion event arrives, so the adapter stays
straight-line code. The component's wasi:cli/run@0.3.0 export (an
async func) is lifted as an async-typed export, from which that blocking
wait is legal. All of this sits on the base component-model async ABI,
enabled by default in wasmtime 46+ — no gated feature flags remain; only
-W gc=y (for the wasm-GC core) is needed.
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 Runs Inside a Component
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. In an async body a pending stdinread-line/read-charsuspends only its own task, like a socket read — a concurrentrontolisp:wait-fortimer keeps running while the program waits for input. File access requires--dir(a relative path resolves against the first preopened directory, an absolute one against the preopened directory whose name is its longest prefix):
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 --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), anduiop:getenvreadswasi:cli/environment@0.3.0.- Outgoing HTTP (
rontolisp:fetchwith therontolisp:await/rontolisp:futurepfuture operations) works in component mode, including true asynchrony:fetchsends the request and returns a future (wrapping the in-flightwasi:httpresponse handle) immediately, so several requests can overlap beforeawaitsuspends on each. The future operations themselves compile in every mode; onlyfetchis component-only. fetch imports the asyncwasi:http@0.3.0(wasi:http/types+wasi:http/client) — uniformly WASI 0.3, like the rest of the component. Run a fetch component with-S http=y(which makes the host providewasi:http) in addition to the usual flags. Non-fetch components do not importwasi:http, so they do not need-S http. A transport failure (refused connection, unresolvable host) signalsrontolisp:wit-erroratawaittime on every backend;nilcomes back only for a request that cannot be started. See the HTTP fetch guide for the request/response shape. - 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/write-string/read-byte/write-byte/closework on it directly. Run a socket component with-W exceptions=y -S tcp=y -S inherit-network=yin addition to the usual flags (a tcp component always compiles in exception-handling mode); without the-Sflags the component still starts but every socket operation fails and yieldsnil. Hosts must be IPv4 literals (no hostname resolution yet).rontolisp:fetchand the tcp functions can be combined in one component, and tcp works inside arontolisp:http-handler(serve) component. In an async body a pendingtcp-acceptor socket read suspends only its own task — other tasks (arontolisp:wait-fortimer, another request) keep running. See the TCP sockets guide for the full API. - 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 (exportingwasi:http/handler@0.3.0) run underwasmtime serve— see the HTTP handler guide.
The imported surface follows the program, because
tree shaking narrows it: a
component that only prints imports
wasi:cli/{types,stdout} and nothing else — wasi:cli/stderr joins only when
the program can actually write there (warn,
*error-output*, or the report an uncaught condition prints) — which is what a wasm-tools component wit on it, and the
--emit-wit output below, will show. Nothing about the flags you run it with
changes; a host simply has less to provide. A component built with
--optimize=off instead declares all of the above whether or not the program
uses them, so its imported surface is the same for every program.
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 --invoke 'sumsquared(2, 3)' sumsq.wasm
# 25 (the export's return value, rendered by wasmtime)
wasmtime run -W gc=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 (each integer designator under its own WIT name — :s32
→ s32, :u32 → u32, … — plus :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 --invoke 'greet("世界")' greet.wasm
# "Hello, 世界"
By default an export is lifted synchronously. Even so, I/O inside it
usually works: the asynchronous built-ins complete without blocking whenever
the host accepts immediately (stdout does), and only a host that reports
BLOCKED forces the blocking wait, which traps in a synchronous task with
"cannot block a synchronous task". Declare the export async with
:async t to lift it against an async function type instead — the same
async-typed lift as the run entry — and remove that residual risk.
wasmtime --invoke calls an async export exactly the same way:
rontolisp status.lisp --component -o status.wasm
wasmtime run -W gc=y -W exceptions=y -S http=y \
--invoke 'fetch-status("https://httpbin.ik.am/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 can usually do I/O anyway (the asynchronous
built-ins complete without blocking when the host accepts immediately);
only a host that reports BLOCKED makes the blocking wait trap with
"cannot block a synchronous task". Opt into
:async twhen the export prints, fetches, or otherwise does I/O to remove that residual risk; 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 support for the 0.3 async ABI is 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). The reactor shape below removes this: its top level runs at instantiation.
For a pure-compute export kit, the compact
--no-gc --component
variant emits the same typed exports (minus :s-expr) in a component of a
few hundred bytes that needs no wasmtime flags at all.
Reactor Components (--component --no-wasi)
Add --no-wasi to emit a reactor component: a component that imports
nothing. There is no WASI surface at all — wasm-tools component wit
shows not a single import line, at every --optimize level — so any
component host instantiates it with an empty import object, and its only
exports are the lifted wasm-export functions (there is no wasi:cli/run
entry):
$ rontolisp greet-reactor.lisp --component --no-wasi -o greet.wasm
$ wasm-tools component wit greet.wasm
package root:component;
world root {
export greet: func(p0: string) -> string;
}
$ wasmtime run -W gc=y --invoke 'greet("world")' greet.wasm
"hello, world"
Unlike every other component shape, the top-level forms run at
instantiation (the core module's start section), so the defparameter
above is already assigned when the first export call arrives — no host
cooperation needed. The flip side: a top-level form that traps now prevents
instantiation itself.
The I/O contract is the Preview 1 reactor's, unchanged: output (print,
format t) is discarded, input/time/random trap, and
with-open-file/open signal a catchable error. Anything that would put
an import back refuses to compile with --no-wasi — rontolisp:fetch,
rontolisp:http-handler, rontolisp:wait-for and rontolisp:wit-import
each report the conflict by name.
For a JavaScript embedder this is the component shape that needs no WASI
shim at all: jco transpile greet.wasm --instantiation sync generates glue
whose ImportObject type is literally empty ({}). The same applies to a
clack:clackup ... :server :rontolisp application — the reactor build's
handle-request export (see
the Clack guide) works
under --component --no-wasi too, giving a Clack application a typed
handle-request: func(p0: string) -> string component export.