(rontolisp) docs

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-export in the same program is a compile error.
  • Every export must have a matching defun of the right arity, every WIT type must be one the boundary carries (every fixed-width integer s8u64, plus f64, bool, string), and an async func in 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.lisp run (or a -o Greet.class build) 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. import items are ignored (a component's WASI imports come from the build, not from the world — --emit-wit is how you see them), and an inline import name: func(...) is rejected rather than silently dropped; the functions a program calls are bound from an interface with wit-import (or declared by hand with rontolisp: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 a rontolisp:http-handler program cannot use a world at all (a serve-mode component's only export is wasi:http/handler@0.3.0).
  • :s-expr has no WIT spelling, so an export passing an arbitrary s-expression across the boundary still needs a hand-written rontolisp: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-errorNo 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-gc rejects 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-import can carry cross — the integer scalars up to 32 bits, the float scalars, bool, string, list<u8> and resource handles. A record, option, result or s64 is a compile error naming the WIT file and line, even though --component, the interpreter and the JVM all bind it (the wasi:keyvalue program above is therefore a component or an interpreter/JVM program, not a Preview 1 one: its result arms keep it off that boundary). A core import is a bare host function, with no component type to describe a richer shape with. stream and future are rejected on every backend.
  • Under --component a list<T> argument (other than list<u8>), and flags anywhere, is a compile error; a list<T> still crosses as a result.
  • The directive binds an interface. A world's import items 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.