Serving HTTP (http-handler)
Hand-rolling HTTP over read-line/write-line (as the
TCP Sockets guide demonstrates with http-hello.lisp) is
instructive, but for a plain request/response server
rontolisp:http-handler
does the parsing for you. You write a handler that takes the Clack
environment property list (:request-method / :path-info /
:query-string / :headers / :raw-body / ...) and returns the Clack
response list (status headers body) — the protocol of
Clack Web Applications, which is why a Clack application is
served with zero per-request conversion:
(defun handle (env)
(list 200 '(:content-type "text/plain")
(list (format nil "Hello from rontolisp!~%~a ~a~%"
(getf env :request-method) (getf env :path-info)))))
(rontolisp:http-handler 'handle 8080)
Save it as app.lisp (also shipped as
examples/net/http-handler.lisp),
then run it on any of the three supported backends below.
The handler contract
The handler receives Clack's environment property list, with these keys — always all present:
| env key | value |
|---|---|
:request-method | the method as an upcased interned keyword (:GET, :POST, ...), so (eq m :POST) works |
:script-name | the application's mount point, percent-decoded — "" everywhere but a Servlet war deployed under a context path |
:path-info | the percent-decoded request path, with the mount point stripped first |
:query-string | the raw text after the first ?, or nil when there is none |
:server-name / :server-port | from the Host header when present, otherwise the listener's |
:server-protocol | a keyword, e.g. :HTTP/1.1 |
:request-uri | the raw request target verbatim (still percent-encoded, query included) |
:url-scheme | "http" or "https" |
:remote-addr / :remote-port | the real peer on the interpreter and the JVM; nil on the WASI component (wasi:http@0.3.0 exposes no peer accessor) |
:headers | an equal hash table keyed by lowercased header names — look up with (gethash "content-type" (getf env :headers)); repeated headers join with ", "; never nil |
:content-type / :content-length | from that table (nil when absent; :content-length an integer) |
:raw-body | the request body (below) |
By default :raw-body is rontolisp's asynchronous stream: a handler that
reads it drains it with
(rontolisp:await (rontolisp:read-all (getf env :raw-body))) and must be an
rontolisp:async-defun.
With the optional directive argument
(rontolisp:http-handler 'handle 8080 :raw-body :buffered) the body is
instead read in full up front and handed over as a synchronous in-memory
bivalent stream — readable with read-line/read-char and
read-byte/read-sequence, with a real file-position — which is what a
Clack application (lack-request, http-body) needs; a bodiless request then
gets :raw-body nil.
The handler returns Clack's positional response list (status headers body):
status— a required integer; a non-integer car signals an error.headers— a keyword plist ('(:content-type "text/plain"), the idiomatic form) or a dotted alist — accepted so arontolisp:fetchresult's:headerscan be passed straight through. Repeated names each become their own header line (repeated:set-cookieis correct by construction);content-length/transfer-encodingare dropped (the server computes them);nilis fine.body— a list of strings (joined),nilor omitted (an empty body — the two-element(status headers)form is valid), an(unsigned-byte 8)vector (written byte for byte — a binary response is byte-exact), or a rontolisp stream (e.g. a proxied fetch body). A bare string signals an error — deliberately, and faithfully to Clack, which refuses strings too. A pathname body means "serve this file" (lack's static-file middleware answers one); it is a distinct value here and is refused as unsupported until the transport can serve it. A function response is supported in Clack's delayed form only —(lambda (responder) ... (funcall responder (list 200 nil (list "later"))))— and the streaming-writer form is refused.
One migration hazard is worth spelling out for handlers written against the
pre-Clack contract: the response side fails loudly (the errors above), but
the request side fails silently — (getf env :method) in a half-migrated
handler just returns nil.
The client side is unchanged: rontolisp:fetch still
yields its (:status <integer> :headers <alist> :body <stream>) result
plist.
On the interpreter
http-handler starts a blocking embedded HTTP server on port 8080 (one
virtual thread per request) and serves until the process is stopped with
Ctrl-C:
$ rontolisp app.lisp
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
The listener binds the wildcard address (0.0.0.0, dual-stack), not
loopback, so the port is reachable from other machines as soon as the host
allows it. The directive has no address argument: to choose the bind address —
loopback only, or one specific interface — serve the same application through
clack:clackup, whose :address defaults to 127.0.0.1.
Compiled to a JVM class
The same source compiles to a JVM class serving the same way, and the class is self-contained: the embedded server it serves through travels beside it, at its canonical name, so nothing has to be on the classpath but the output directory itself.
$ rontolisp app.lisp -o App.class
$ ls
App.class am/ app.lisp
$ java -cp . App
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
The am/ik/rontolisp/runtime/ directory beside App.class is that server:
a handful of class files that import nothing outside the JDK, the same
mechanism by which a JVM library hands out its
RontoFloatArray handle. A program that does not serve still compiles to
exactly one file.
Compiling to a jar packages them together, so the result runs on its own:
$ rontolisp app.lisp -o app.jar
$ java -jar app.jar
The Maven plugin writes them
into target/classes the same way, so a src/main/lisp service ends up
inside the jar the build produces.
Compiled to a Servlet war
The same source also compiles to a Servlet war that deploys unmodified, with no configuration, on any Servlet 6 container (Tomcat 10.1/11, Jetty 12, and every current Jakarta EE server):
$ rontolisp app.lisp -o app.war
$ cp app.war $CATALINA_HOME/webapps/ROOT.war
The war carries no web.xml and no file naming the program class: the
container discovers the compiled class itself (it implements the handler
interface the JVM class output already implements) and registers a servlet at
/*. The container owns the port, so a port written in the
rontolisp:http-handler form is ignored with a one-line warning at compile
time. --maven-coordinates and --emit-pom work as they do for a jar.
The war also serves correctly under a non-root context path (deployed as
myapp.war rather than ROOT.war): the container's mount point reaches the
handler as :script-name and :path-info carries only the remainder, so a
router matches the same routes wherever the war is mounted. This is the only
transport where :script-name is not "".
The servlet is asynchronous by default: each request releases the
container's thread and runs the handler on its own virtual thread, the same
one-virtual-thread-per-request rule every other rontolisp transport keeps. A
container already configured with virtual threads can opt out with a
rontolisp.async context parameter of false. If a filter in the chain does
not declare async support, the war falls back to the synchronous path with one
warning naming that parameter, rather than failing requests.
You may add your own web.xml to the war afterwards (a filter, a security
constraint, a <session-config>); the initializer keeps working even under
metadata-complete="true" or <absolute-ordering/>.
Two failure shapes worth knowing:
- A top-level form that signals surfaces as
ExceptionInInitializerErrorand fails the deployment — the container reports a failed webapp instead of answering 500 forever. - The handler slot is per webapp, not per process: each webapp has its own class loader, so two rontolisp wars deploy side by side in one container.
A Clack application compiles to a war the same way — clack:clackup ... :server :rontolisp picks the servlet transport when the output is a
.war, with no change to the source.
The Maven plugin builds the same war from a
<packaging>war</packaging> project's src/main/lisp, with one plugin parameter
(<servlet>true</servlet>) standing in for -o app.war.
Compiled to a WASI HTTP component
It also compiles to a WASI HTTP component that runs under
wasmtime serve (wasmtime 47+ — 46 also serves it, but collapses under
concurrent load; see the throughput section below):
$ rontolisp app.lisp -o app.wasm --component
$ wasmtime serve app.wasm
$ curl http://127.0.0.1:8080/hello
Hello from rontolisp!
GET /hello
There the module exports wasi:http/handler@0.3.0 (the async WASI 0.3 HTTP
world) and the host owns the socket, so the port argument is ignored. The
component uses the WebAssembly GC proposal and the exception-handling
proposal (the Lisp-written HTTP glue uses it to detect end-of-body), both
default-on in wasmtime 47+. The handler is lifted as a
callback async export: a handler that suspends (awaiting a timer, a fetch
or a body read) hands control back to the host, which delivers each completion
event through the component's callback — all of it part of the base
component-model async ABI, which is default-on in wasmtime 46+, so no gated
feature flags are needed. The response is still delivered mid-task through
canon task.return, and the body streams after it.
Other WASI HTTP runtimes
The component asks its host for wasi:http 0.3 (async) plus wasm-GC.
wasmtime 46+ serves it, and so does wasmCloud: wash 2.5.2 runs it with
wash dev, given
dev.wasm_proposals: [gc, exception-handling, component-model-async] in the
project manifest. Install it with
curl -fsSL https://wasmcloud.com/sh | bash — verified on wash 2.6.1. (Binaries
picked by tag from the separate wasmCloud/wash repository are a different, older
line: 2.0.0-rc.x offers wasi:http 0.2 only and rejects the component while
extracting its interfaces.)
Spin runs it too, from the
canary build
(4.1.0-pre0) on — its embedded
wasmtime is 47, which enables the WebAssembly GC and exception-handling
proposals by default, so no flag is needed. Drop a spin.toml beside the
program:
spin_manifest_version = 2
[application]
name = "rontolisp-http-handler"
version = "0.1.0"
[[trigger.http]]
route = "/..."
component = "hello"
[component.hello]
source = "app.wasm"
[component.hello.build]
command = "rontolisp app.lisp -o app.wasm --component"
$ spin build && spin up
Serving http://127.0.0.1:3000
$ curl http://127.0.0.1:3000/hello
Hello from rontolisp!
GET /hello
Spin owns the socket and listens on 3000, so the port argument is ignored
here as well. A handler that calls rontolisp:fetch also needs
the upstream host on the component's allowed_outbound_hosts — Spin denies
outbound HTTP by default:
[component.dog]
source = "app.wasm"
allowed_outbound_hosts = ["https://dog.ceo"]
Released Spin 4.0.2 cannot run the component. Its embedded wasmtime is 44,
which speaks the wasi:http@0.3.0-rc-2026-03-15 snapshot rather than the
released wasi:http@0.3.0, so the imports fail to link even with GC turned on
(and the released 4.0.2 binary has no switch to turn GC on: the
--experimental-wasm-feature option is compiled into canary builds only).
jco cannot run it either — it does not implement the 0.3 async ABI.
Graceful shutdown
A server started by the directive drains on the way out. When the process is asked to terminate — SIGTERM from a container or an orchestrator, SIGINT from Ctrl-C — the listening socket closes at once, so nothing new is accepted, and the requests already being handled are given up to 30 seconds to finish before their connections are cut. A rolling deploy therefore ends with whole responses instead of connection resets:
$ java -jar app.jar &
$ curl http://127.0.0.1:8080/slow & # a handler that takes a while
$ kill %1 # SIGTERM: the /slow response still arrives
Change the 30 seconds with the rontolisp.http.shutdown-grace system property
or the RONTOLISP_HTTP_SHUTDOWN_GRACE environment variable, in seconds; 0
cuts in-flight requests immediately. Size it under the deadline your platform
allows — Kubernetes sends SIGKILL after terminationGracePeriodSeconds
(30 by default), and a grace period longer than that is never reached.
This is the JDK server behind the interpreter and the compiled JVM class or jar. A Servlet war drains the way its container does, and a WASI component the way its host does; neither owns the socket, so neither takes its shutdown from here.
An explicit stop from inside the program (clack:stop) is not graceful, and
deliberately so: a handler may stop its own server, and waiting there would
have the stop wait on the very request that asked for it.
Query strings
:path-info carries the (percent-decoded) path only, so route comparisons
are exact. When the request has a query string it arrives separately under
:query-string — the raw text after the first ?, or nil when there is
none. Parse it with the query-string functions of the URL library,
rontolisp:query-param and
rontolisp:query-params
(both url-decode keys and values, and both accept nil):
(defun handle (env)
(list 200 '(:content-type "text/plain")
(list (format nil "Hello, ~a!~%"
(or (rontolisp:query-param (getf env :query-string) "name")
"world")))))
(rontolisp:http-handler 'handle 8080)
$ curl 'http://127.0.0.1:8080/greet?name=ronto%20lisp'
Hello, ronto lisp!
$ curl http://127.0.0.1:8080/greet
Hello, world!
Calling other services from a handler
rontolisp:fetch works inside a served handler on all three
backends, enabling the classic proxy / aggregator shape. A handler that awaits
is an asynchronous function, so define it with
rontolisp:async-defun
instead of defun:
(rontolisp:async-defun handle (env)
(let ((res (rontolisp:await
(rontolisp:fetch "http://127.0.0.1:9000/upstream"))))
(list (getf res :status) (getf res :headers) (getf res :body))))
(rontolisp:http-handler 'handle 8080)
The fetch result's :headers alist goes into the response's headers slot
as is, and its :body stream into the body slot — the server drains it, and
because the stream's chunks are the upstream's octets and nothing decodes them
on the way through, the relay is byte-exact: an image comes out as the image
that went in.
On the WASI component backend the outgoing-request machinery rides along in
the same component — serve and serve+fetch are one component shape, importing
wasi:http/client@0.3.0, which wasmtime serve provides by default (no
-S http=y needed):
$ rontolisp proxy.lisp -o proxy.wasm --component
$ wasmtime serve proxy.wasm
A complete example is
examples/net/dog-fetcher.lisp,
a reproduction of
wasmCloud's dog-fetcher example:
every request fetches a random dog picture URL from the dog.ceo API and
answers it as JSON.
Keeping State: a store, not a global
On the interpreter and the JVM the server is one long-lived process, so a global hash table survives between requests. A served component's does not — and the way it does not is worse than "it resets every time". How long an instance lives is the host's decision, and the hosts disagree:
| host | instance lifetime |
|---|---|
wasmtime serve | 128 requests, then retired (--max-instance-reuse-count) |
| Spin | 128 requests (it inherits wasmtime's default) |
wasmCloud wash dev | 1 request — always a fresh instance |
So a global neither survives the run nor resets per request: under wasmtime and Spin the top level runs again on every 128th request, and everything the handler accumulated in a global vanishes with it. Treat top-level side effects as idempotent, and keep anything that must survive outside the component.
The way to keep state is therefore to put it outside the component — in a WIT
interface the handler calls, bound with
rontolisp:wit-import. A served
component imports it alongside its fixed wasi:http surface:
(rontolisp:wit-import "wit/keyvalue.wit"
:interface "wasi:keyvalue/store@0.2.0-draft"
:package kv)
(defun handle (env)
(let* ((page (getf env :path-info))
(bucket (kv:open ""))
(seen (kv:bucket-get bucket page))
(hits (+ 1 (if seen (parse-integer seen) 0))))
(kv:bucket-set bucket page (princ-to-string hits))
(list 200 nil (list (format nil "~a: ~a hits~%" page hits)))))
(rontolisp:http-handler 'handle 8080)
$ rontolisp page-hits-server.lisp -o server.wasm --component
$ wasmtime serve -S keyvalue=y server.wasm
The same source runs on the interpreter and the JVM, where a
provider written in Lisp
answers the interface instead. Whether the counts survive on a component is the
host's business: wasmtime's built-in key-value provider is an in-memory store that
starts empty on every request (verified: each request reports 1 hit), while a host
that links an out-of-process provider — wasmCloud (wash dev), say — keeps them.
The
worked example is
examples/wit/keyvalue.
Throughput, and what the component pays for
The three backends are in the same league on a trivial handler. Measured on one
machine (16 concurrent connections, 10 s closed loop, a handler that answers
"Hello " + :path-info; wasmtime 47.0.2, wasmtime serve at its defaults):
| backend | requests/s | mean | p99 |
|---|---|---|---|
| interpreter | 33 900 | 0.47 ms | 0.99 ms |
| JVM class | 36 600 | 0.44 ms | 0.88 ms |
| WASI component | 24 500 | 0.65 ms | 1.19 ms |
The component row needs wasmtime 47 or newer. On wasmtime 46 a failing runtime type test — the ordinary misses of a dynamic language's type dispatch — is a call into the host that takes an engine-global lock. One connection merely pays it (about 10%); concurrent connections contend for it, and throughput falls as connections are added — to roughly a fifteenth at 16 connections. wasmtime 47 checks these types inline (every type rontolisp emits is final, which is exactly the shape its fast path needs), and the collapse disappears.
The component's gap is instantiation, not the handler: the host runs the
whole top level (_start) once per instance, and it retires an instance every
--max-instance-reuse-count requests. Lowering that knob makes the cost visible
— at --max-instance-reuse-count 1 the same component drops to roughly a third
of the throughput above, because every request pays a full instantiation.
Two consequences worth knowing:
- Where the top level goes matters, and how much depends on the host. Work
done at top level is paid once per instance — amortized over 128 requests
under wasmtime and Spin, but paid on every request under wasmCloud, where
the same handler serves 7 900 rps against wasmtime's 24 500. A
ql:quickload "clack"program and a barerontolisp:http-handlerone serve at nearly the same rate under wasmtime for exactly this reason, and at visibly different rates under wasmCloud. - Tree shaking is about size, not speed here. It shakes the compiled core module (a serve component loses a few percent; a non-serve component can lose 90%), which shortens instantiation slightly, but it does not change the steady-state per-request cost.
Limitations
Request and response headers are marshalled on every backend, including the WASI
component: the handler reads :headers (an equal hash table keyed by
lowercased header names) from the environment and the response's headers
element is written back.
Inside a served component handler, random, the time built-ins and print
(to the host's stdout) all work — the component bridges them to the
wasi:random, wasi:clocks and wasi:cli interfaces every wasi:http host
provides. uiop:getenv reads the host environment too — a served component
imports wasi:cli/environment@0.3.0, so wasmtime serve --env NAME=value
(or -S inherit-env=y) reaches the handler — and file streams are
unavailable. See the
rontolisp:http-handler
reference page for the details.
For the client side of HTTP, use rontolisp:fetch — see the
HTTP Requests guide. To work at the raw socket level instead
(any TCP protocol, or TLS), see the TCP Sockets guide.