WIT Contracts (wit-export / wit-import)
Two directives let a program's boundary come straight from a .wit file
someone else wrote (or that a binding generator produced):
rontolisp:wit-export implements a world, and rontolisp:wit-import
calls an interface. Neither adds a new lowering path — each is a typed
front-end for the manual wasm-export / wasm-import
machinery, plus per-backend implementations that let the same source run
everywhere (typed component-model exports under --component, provider
callbacks on the interpreter and the JVM, byte-identical Preview 1 imports).
Implementing a WIT World (wit-export)
rontolisp:wit-export takes a world someone else wrote, and has the
program implement it:
// wit/greeter.wit
package example:greeter;
world greeter {
/// Greet someone by name.
export greet: func(who: string) -> string;
}
;;; greet.lisp -- the directive comes last: on the interpreter it sees only the
;;; functions defined so far.
(defun greet (who)
(concatenate 'string "Hello, " who "!"))
(rontolisp:wit-export "wit/greeter.wit" :world greeter)
rontolisp greet.lisp --component -o greet.wasm
wasmtime run -W gc=y --invoke 'greet("world")' greet.wasm
# "Hello, world!"
There is no :params '(:string) :returns :string anywhere — the types come
from the world. That is the whole point: hand-written boundary types sit next
to a .wit that is generated separately, and the two drift until
wasmtime --invoke fails at run time. With wit-export the WIT is the
single source of truth:
- The world is the program's export list, so a hand-written
rontolisp:wasm-exportin the same program is a compile error. - Every export must have a matching
defunof the right arity, every WIT type must be one the boundary carries (every fixed-width integers8…u64, plusf64,bool,string), and anasync funcin the world lifts that export with:async t(so an export that does I/O is declared async by the WIT instead of being guessed at). Each mismatch is a compile error naming the WIT file and line:wit/greeter.wit:5: export 'greet' declares 1 parameter(s), but (defun greet ...) takes 2. - The contract is checked on every backend: a plain
rontolisp greet.lisprun (or a-o Greet.classbuild) verifies the world and exports nothing, so a drift is caught long before a WASM build.
The directive is a front-end for the machinery of the previous sections, not
a second export path: it lowers into exactly the rontolisp:wasm-export
directives a hand-written implementation would carry, so the emitted
component is byte-identical to that one — on the GC path and under
--no-gc --component
alike (a world using s64/u64 works on both: the GC backend carries the
64-bit types through its boxed exact integers).
Adding --emit-wit to the build writes
out the component's real type, and its export lines come back the way you
wrote them, parameter names included — the WIT's names ride through into the
component's function type. (A hand-written export names its parameters p0,
p1, ... unless it declares them itself with :param-names '(who).)
rontolisp greet.lisp --component -o greet.wasm --emit-wit # writes greet.wit
export greet: func(who: string) -> string;
That line is a fixpoint, though, not a verdict: it is derived from the
world, so it cannot contradict it. The reason to emit anyway is the rest of
the file — the wasi:* imports and the wasi:cli/run export that the world
says nothing about, and that a host has to supply. greet.wit is 149 lines
around that one export. Two differences from the input are deliberate: the
/// doc comments are gone, because a component's type does not store them
(wasm-tools cannot recover them either), and the emitted world is always
package root:component; world root. That is what a component's type is.
Exporting an interface
Most WIT worlds export an interface rather than a bare function — the idiomatic shape keeps the interface definition separate from the world:
// wit/adder.wit
package docs:adder@0.1.0;
interface add {
add: func(x: s32, y: s32) -> s32;
}
world adder {
export add;
}
wit-export implements this the same way: it resolves export add; to the
add interface defined in the file and checks each of its functions against
the program. The component then genuinely exports the interface, so
wasm-tools component wit and a host see docs:adder/add, not a flattened
top-level function:
;;; adder.lisp
(defun add (x y) (+ x y))
(rontolisp:wit-export "wit/adder.wit" :world adder)
rontolisp adder.lisp --component -o adder.wasm
wasmtime run -W gc=y --invoke 'add(20, 22)' adder.wasm
# 42
An inline export ops: interface { ... } works the same way, keyed by its
plain name, and --emit-wit reconstructs the interface — export docs:adder/add@0.1.0; plus its interface definition — byte-for-byte the way
wasm-tools prints it.
Current limitations:
- Only the world's export side is bound.
importitems are ignored (a component's WASI imports come from the build, not from the world —--emit-witis how you see them), and an inlineimport name: func(...)is rejected rather than silently dropped; the functions a program calls are bound from an interface withwit-import(or declared by hand withrontolisp:wasm-import). - A world exports freestanding functions or an interface defined in the same
file (above); an export naming an interface the file does not define — a
bare
wasi:*reference — is an error, and arontolisp:http-handlerprogram cannot use a world at all (a serve-mode component's only export iswasi:http/handler@0.3.0). :s-exprhas no WIT spelling, so an export passing an arbitrary s-expression across the boundary still needs a hand-writtenrontolisp:wasm-export.- On the interpreter the directive is evaluated in order and sees only the functions defined so far, so put it at the end of the file.
Scaffolding an Implementation (--scaffold-wit)
--scaffold-wit is the answer to "someone handed me a .wit, now what": it
generates the skeleton of an implementation instead of compiling one.
rontolisp --scaffold-wit wit/greeter.wit -o greet.lisp # no -o: print to stdout
;;;; Implementation of the WIT world 'greeter' (wit/greeter.wit).
;;;;
;;;; The world is the contract: the compiler checks every defun below against
;;;; it, so a renamed export, a changed arity or a changed type is a compile
;;;; error rather than a runtime surprise. Fill in the bodies; each one signals
;;;; until you do.
;;; Greet someone by name.
;;; WIT: greet: func(who: string) -> string
(defun greet (who)
(error "greet is not implemented yet"))
(rontolisp:wit-export "wit/greeter.wit" :world greeter)
The parameters are named as the WIT names them, each export's WIT signature
is carried above its stub as the contract it must satisfy, and the /// doc
comments become ;;; comments. The stubs signal at run time, not compile
time, so the generated file compiles unchanged and the exports can be filled
in one at a time. A world exporting an interface scaffolds one stub per
interface function, so the separated shape above yields the same skeleton. Add
--world NAME when the .wit declares several worlds.
Emitting the WIT World (--emit-wit)
Add --emit-wit to any --component build to also write the component's
WIT description next to the .wasm output — -o sumsq.wasm --emit-wit
writes sumsq.wit:
rontolisp sumsq.lisp --component -o sumsq.wasm --emit-wit
// sumsq.wit (the world; the file also carries the referenced package
// definitions, so it is self-contained and parseable on its own)
package root:component;
world root {
import wasi:cli/types@0.3.0;
import wasi:cli/stdout@0.3.0;
// ... the WASI imports of the build's blob variant ...
export wasi:cli/run@0.3.0;
export sumsquared: func(p0: s32, p1: s32) -> s32;
}
The text matches what wasm-tools component wit sumsq.wasm prints for the
same bytes, so it is exactly the component's real surface — but nothing needs
to introspect the binary anymore: hand the .wit straight to a binding
generator. For example, jco generates TypeScript typings from it without
touching the .wasm:
npx @bytecodealliance/jco types sumsq.wit -o types/
# types/sumsq.d.ts: export function sumsquared(p0: number, p1: number): number;
The world's imports follow the build variant (plain, rontolisp:fetch,
rontolisp:tcp-*, or rontolisp:http-handler; with
--no-gc --component
the world is import-free, or carries the wasi:cli/stdout@0.3.0 import — and
async func exports — when the program prints) and, since
tree shaking is on unless you pass
--optimize=off, the part of that variant the program can actually reach: the
world above is the two wasi:cli imports it really needs, not the build
variant's full fixed surface. An :async t export is
rendered as async func, and a rontolisp:http-handler build exports
wasi:http/handler@0.3.0 instead of run. --emit-wit without --component
is a compile error — a core module has no WIT-level surface to describe.
What --emit-wit Is For
It answers different questions depending on where the export list came from.
A program without a world — exports written by hand with
rontolisp:wasm-export, or an :s-expr export, which has no WIT spelling at
all — has no .wit anywhere. --emit-wit is the only way to get one, exactly
as above.
A program with a world (wit-export)
has already written its exports down. What it has not written down is the
component's imports, and that is the larger half: wit-export reads only
the world's export items, because a component's WASI surface comes from the
build, not from the world. The 6-line
wit/greeter.wit of the previous section
compiles to a component whose real type is 26 lines — two wasi:cli
imports and export wasi:cli/run@0.3.0 wrapped around the one greet you
declared, which is as narrow as
tree shaking can make it. (Build
the same source with --optimize=off and you get the build variant's full
fixed surface instead — 174 lines, eleven wasi:* imports — which is one
more reason to read the emitted world rather than assume it.) Let that same
greet call rontolisp:fetch and the build silently adds four more imports
(wasi:http/types and wasi:http/client, plus the wasi:filesystem/types and
wasi:cli/stderr the HTTP path can reach), for 190 lines;
rontolisp:tcp-* pulls in wasi:sockets the same way. Short of
installing wasm-tools and introspecting the binary, --emit-wit is the
only way to see what you actually built — and it is precisely what a host,
or jco, needs in order to supply those imports.
What --emit-wit is not — for a program that has a world — is a drift
check on that program. The export lines are a fixpoint by construction: the
world produces the rontolisp:wasm-export directives, those produce the
component's function types, and those are what is printed back out, over a
boundary type set (every fixed-width integer, plus f64, bool, string)
that maps one-to-one in both directions. They cannot come out disagreeing with the world you
handed in. Re-emitting the .wit and diffing it in CI is therefore a
regression check on rontolisp's type mapping — cheap, and worth keeping —
not a check on your source. The thing that catches a drifted program is
wit-export itself, and it already runs on every backend, including a plain
interpreter run. This is transitional: once a world can also declare the
imports a program binds, the emitted WIT becomes a genuinely two-sided
contract.
Importing a WIT Interface (wit-import)
wit-export is the export side of a WIT contract.
rontolisp:wit-import is the import side: it declares that the program
calls a WIT interface, and binds every function that interface declares
as an ordinary Lisp function — its name, its lambda list and its types all
taken from the .wit. It is a compile-time directive that lowers into forms
that already exist, and what it lowers to depends on the backend. That is
the whole point: one WIT, a different implementation per backend, zero
source changes.
// wit/host.wit
package example:host@0.1.0;
interface math {
/// Add two integers on the host.
add-ints: func(a: s32, b: s32) -> s32;
}
;;; main.lisp -- the directive comes FIRST: it defines the functions the rest of
;;; the file calls.
(rontolisp:wit-import "wit/host.wit" :interface "example:host/math@0.1.0")
(defun add10 (n) (add-ints n 10))
(rontolisp:wasm-export 'add10 :params '(:int) :returns :int)
On Preview 1 WASM each WIT function becomes a
rontolisp:wasm-import: the
import module is the interface's bare name (math, overridable with
:from) and the import field is the WIT label in camelCase (addInts —
the JavaScript convention, and what jco produces; :field-style :kebab
keeps the label verbatim). So the host is satisfied exactly as before — here
by another Lisp module that exports the function under that field name:
;;; host.lisp
(defun host-add (a b) (+ a b))
(rontolisp:wasm-export 'host-add :as "addInts" :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 math=host.wasm --invoke add10 main.wasm 32
# 42
The module is byte-identical to the one the hand-written
(rontolisp:wasm-import 'add-ints :from "math" :as "addInts" :params '(:int :int) :returns :int)
produces — the directive is a typed front-end for that machinery, not a
second import path — and the
tree shaker
still shakes out the imports the program never calls, so binding a 29-function
interface and using three of them costs nothing.
Providers: the same source on the interpreter and the JVM
There is no WASM host on the interpreter or the JVM, so there each WIT
function becomes an ordinary defun that dispatches through the interface's
provider: a Lisp callable taking the bound function's Lisp member name (a
string) followed by that function's arguments.
rontolisp:wit-provide
binds one — and rontolisp ships no provider for any interface. It knows
the provider mechanism; it does not know what wasi:keyvalue is. Implementing
a WIT interface is ordinary Lisp code:
;;; counter.lisp -- wasi:keyvalue, against a store written in Lisp.
(rontolisp:wit-import "wit/store.wit" :interface "wasi:keyvalue/store@0.2.0" :package kv)
(defvar *rows* (make-hash-table :test #'equal))
(defun my-store (member &rest args)
(cond ((string= member "open") 1) ; the bucket handle: any integer
((string= member "bucket-set")
(setf (gethash (nth 1 args) *rows*) (nth 2 args))
nil)
((string= member "bucket-get") (gethash (nth 1 args) *rows*))
(t (error 'rontolisp:wit-error :payload (list :other member)))))
(rontolisp:wit-provide "wasi:keyvalue/store@0.2.0" #'my-store)
(defvar *bucket* (kv:open "counts"))
(kv:bucket-set *bucket* "visits" "41")
(print (kv:bucket-get *bucket* "visits")) ; "41"
:package kv synthesizes the defpackage that exports the bindings, a WIT
resource method takes its handle as the first argument (bucket.get
becomes (kv:bucket-get b "visits")), and each binding is an ordinary
function, so #'kv:bucket-get, funcall and mapcar work on it. Calling
one with no provider bound signals rontolisp:wit-error — No provider is bound for the WIT interface wasi:keyvalue/store@0.2.0 -- bind one with rontolisp:wit-provide — rather than reaching some default.
The payoff is that a provider is just a function: swap the hash table above
for a real store — Redis, a file, a JDBC connection — and the code calling
(kv:bucket-set b "visits" "41") does not change. The
wit/keyvalue example
runs one page-view counter over three of them (a portable Lisp store, a
java.util.LinkedHashMap one on the JVM, and wasmtime's own wasi:keyvalue
implementation as a component) with identical output. Compile the same
source to WASM instead and the host implements the interface: a top-level
rontolisp:wit-provide is then dropped (the host is the provider),
rather than being an error, precisely so that one source runs everywhere.
A WIT result<T, E> is not a value: the ok arm is the return value, and the
error arm signals the rontolisp:wit-error condition carrying the mapped
E, which handler-case catches and rontolisp:wit-error-payload unpacks.
Components: the host is the provider (--component)
Compile the very same source with --component and the interface becomes a
real component-model import: the component declares it in its type, and
every bound function is canon lowered into the core module, so the calls
go out through the canonical ABI. There is no provider inside the component
at all — the host is the provider, and any host (or any other component)
that exports the interface satisfies it. wasmtime implements wasi:keyvalue,
so a program written against it runs with no adapter and no rewriting:
rontolisp counter.lisp -o counter.wasm --component
wasmtime run -W gc=y -W exceptions=y \
-S keyvalue=y counter.wasm
The canonical ABI is what marshals the rich types, so the component boundary
carries much more than the Preview 1 one: a result (whose error arm arrives
as a rontolisp:wit-error condition, caught with handler-case), an
option, a record (a keyword plist), a variant, an enum, a tuple, a
list<T>, a list<u8>, a string, a bool, and resource handles.
Everything but list<T> crosses in both directions, and an argument takes
exactly the shape the same type takes as a return value — so a value one call
hands you goes straight into the next:
;;; wasi:http/types, imported and called: a variant argument, whose `other` case
;;; carries a string
(http:outgoing-request-set-method req :post)
(http:outgoing-request-set-method req '(:other . "PATCH"))
(http:outgoing-request-method req) ; => (:other . "PATCH")
;;; wasi:sockets/types: an enum argument, then a variant whose case payload is a
;;; record (a keyword plist) carrying a tuple (a positional list)
(let ((s (sock:tcp-socket-create :ipv4)))
(sock:tcp-socket-bind s '(:ipv4 :port 0 :address (127 0 0 1))))
The one shape that still does not lower is a list<T> argument
(list<u8> does, as a byte string): an argument is flattened, and a list
would have to be written into linear memory as a canonical array instead. It
is a compile error naming the WIT line, and flags does not cross in either
direction yet.
One interface a component cannot bind is one it already imports for its
own WASI surface — and that surface grows with what the program uses
(rontolisp:fetch pulls in wasi:http/types and wasi:http/client, the
rontolisp:tcp-* built-ins pull in wasi:sockets/types). A component
cannot import the same interface twice, so that is a compile error too:
drive the interface through the WIT binding instead of the built-in, not
alongside it.
A component imports only the functions the program actually calls (there
is no core tree shaker on this path, so unused interface members are dropped
from the import itself; --no-prune keeps them all), and
--emit-wit writes that pruned
interface into the component's world — where wasm-tools component wit
agrees with it, byte for byte. A component that imports nothing is
byte-identical to one built before any of this existed.
That is also how components compose: a component that imports
wasi:keyvalue/store plugs into any component that exports it, in any
language, with wac. The host
does not have to be a runtime built-in.
A served handler with a real store
A served component (rontolisp:http-handler +
--component) imports user interfaces the same way: its imports are not only
the fixed wasi:http surface it exports through. That is what lets a handler
keep state at all — a wasi:http host instantiates the component afresh
for every request, so a global hash table reads back empty every time,
while a store lives outside it:
rontolisp page-hits-server.lisp -o server.wasm --component
wasmtime serve -W gc=y -W exceptions=y -S keyvalue=y server.wasm
curl http://127.0.0.1:8080/index
Whether the counts then survive is the host's business, not the
component's: wasmtime's built-in key-value provider is an in-memory store it
rebuilds per instance (so, under wasmtime serve, per request), while a host
that links an out-of-process provider keeps them — on wasmCloud (wash dev)
the same component counts 1, 2, 3. The interfaces a served component may
not bind are the ones its own surface already imports: wasi:http/types,
wasi:http/client, wasi:cli/types, wasi:cli/stdout, wasi:cli/stderr,
wasi:clocks/* and wasi:random/random.
The full example is examples/wit/keyvalue.
Releasing a resource (<resource>-drop)
A handle has to be given back, and WIT declares no function for giving it
back: releasing a resource is a canonical built-in of the component model,
not a member of the interface. So rontolisp names it — <resource>-drop,
one argument, the handle — symmetric with the <resource>-new a constructor
binds:
(let ((bucket (kv:open "")))
(kv:bucket-set bucket "visits" "41")
(print (kv:bucket-get bucket "visits"))
(kv:bucket-drop bucket))
It is bound only when the program names it (--no-prune and --dynamic
bind every resource's drop instead), which is why a component compiled before
drops existed comes out byte-identical — a WIT function, by contrast, is
bound whether the program calls it or not. On the interpreter and the JVM
the drop reaches the interface's provider as the member "bucket-drop", so
what it means is the provider's decision: forget the handle, close the
connection, or answer nil because there is nothing to release. On Preview 1
it is a no-op — a handle there is an opaque integer the host handed over,
and rontolisp will not invent an import for a function the WIT never
declared. Under --component it becomes canon resource.drop, handing the
handle back to the host's own table.
This is not only about leaks. An interface may make dropping an
obligation: wasi:http requires an outgoing-body's child
output-stream to be dropped before the body is finished, and traps if it
is not. And a drop releases the reference, never the thing behind it —
the store stays, and the next kv:open sees every key still in it.
Current limitations:
--no-gcrejects the directive with a clear error: its contract is a plain MVP module that imports nothing at all.- On the Preview 1 boundary only the types
rontolisp:wasm-importcan carry cross — the integer scalars up to 32 bits, the float scalars,bool,string,list<u8>and resource handles. Arecord,option,resultors64is a compile error naming the WIT file and line, even though--component, the interpreter and the JVM all bind it (thewasi:keyvalueprogram above is therefore a component or an interpreter/JVM program, not a Preview 1 one: itsresultarms keep it off that boundary). A core import is a bare host function, with no component type to describe a richer shape with.streamandfutureare rejected on every backend. - Under
--componentalist<T>argument (other thanlist<u8>), andflagsanywhere, is a compile error; alist<T>still crosses as a result. - The directive binds an interface. A world's
importitems are still not read. - It must appear at top level before the code that calls the interface —
it is what defines the package and the bindings — which is the opposite of
wit-export.
The wit-import and wit-provide reference pages carry the full option list, the name-mapping rules and the WIT type table.