(rontolisp) docs

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 keyvalue
:request-methodthe method as an upcased interned keyword (:GET, :POST, ...), so (eq m :POST) works
:script-namethe application's mount point, percent-decoded — "" everywhere but a Servlet war deployed under a context path
:path-infothe percent-decoded request path, with the mount point stripped first
:query-stringthe raw text after the first ?, or nil when there is none
:server-name / :server-portfrom the Host header when present, otherwise the listener's
:server-protocola keyword, e.g. :HTTP/1.1
:request-urithe raw request target verbatim (still percent-encoded, query included)
:url-scheme"http" or "https"
:remote-addr / :remote-portthe real peer on the interpreter and the JVM; nil on the WASI component (wasi:http@0.3.0 exposes no peer accessor)
:headersan 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-lengthfrom that table (nil when absent; :content-length an integer)
:raw-bodythe 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 a rontolisp:fetch result's :headers can be passed straight through. Repeated names each become their own header line (repeated :set-cookie is correct by construction); content-length/transfer-encoding are dropped (the server computes them); nil is fine.
  • body — a list of strings (joined), nil or 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 ExceptionInInitializerError and 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:

hostinstance lifetime
wasmtime serve128 requests, then retired (--max-instance-reuse-count)
Spin128 requests (it inherits wasmtime's default)
wasmCloud wash dev1 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):

backendrequests/smeanp99
interpreter33 9000.47 ms0.99 ms
JVM class36 6000.44 ms0.88 ms
WASI component24 5000.65 ms1.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 bare rontolisp:http-handler one 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.