Ring compatibility
This page is a reference for checking whether an existing Ring application will run on Busker. It lists the request fields Busker supplies, the response forms it accepts, and where Busker differs from other Ring servers.
Busker accepts only synchronous (one-argument) Ring handlers; a handler receives one request map and returns one response map.
Ring’s asynchronous (three-argument) handler API is not supported, and neither is middleware that requires it. Busker requires JDK 25 and runs each handler on a virtual thread. Under this architecture threads are no longer a scarce resource that need to be pooled and managed, instead they are an abundant resource that can be created as needed to satisfy your application needs.
When a handler has to wait, for a request body, a database, or another service, it blocks only its own virtual thread, which the JVM parks until the data arrives.
Ring’s async callback style was proposed to avoid exactly that kind of blocking, which makes sense when using platform threads (of which there are ususally only the number of CPUs*2 available). Busker takes the opinion that one concurrency model for application code is enough, and we chose virtual threads.
Most handlers never need to think about threads at all. Waiting is what virtual threads are good at, and web handlers spend most of their time waiting for I/O.
Two exceptions matter. CPU-intensive work does not park a virtual thread, so a heavy handler holds a carrier thread for its whole run; offload that work to a platform-thread pool. And a virtual thread remains pinned to its carrier platform thread while it executes native code.
Request maps
Busker supplies these Ring keys on every request.
It sets :body or :query-string to nil when the request has no body or query string.
| Key | Value | Notes |
|---|---|---|
|
|
Port selected from the request authority. |
|
|
Host selected from the request authority. |
|
|
Client address, usually numeric for TCP and possibly nonnumeric or empty for Unix sockets or lookup failures. |
|
|
Path without the query string. |
|
|
Text after |
|
|
|
|
|
Lowercase method such as |
|
|
|
|
|
Header names are lowercase. |
|
|
A stream for the request body. |
Ring requires :headers to be a map.
Busker currently sets it to nil when the libh2o request contains no ordinary headers.
This empty-header case is a known Ring incompatibility.
Busker does not supply Ring’s deprecated :character-encoding, :content-length, or :content-type request keys.
Read those values from :headers.
It also does not supply :ssl-client-cert, as client certificate authentication is not implemented.
Busker joins duplicate request header values in arrival order.
It uses the ASCII comma delimiter for ordinary fields and the ASCII semicolon delimiter for cookie, as Ring requires.
Busker request keys
| Key | Value | Meaning |
|---|---|---|
|
|
The inferred entrypoint, a named listener group. |
|
|
True when TLS accepted the request as 0-RTT early data. |
|
The response stream for this request. |
Busker infers :ol.busker/entrypoint by server port, then by a sole entrypoint for the request scheme, and finally by the first configured entrypoint.
This value can be wrong when listeners cannot be distinguished by port, such as multiple Unix sockets.
The early-data flag is always present, including on ordinary requests where its value is false.
Busker’s wrap-reject-early-data middleware can reject unsafe methods received through 0-RTT.
Request bodies
A handler reads :body as an InputStream.
Busker requests another libh2o body chunk as the handler consumes the current chunk, so a slow reader does not make Busker read the whole upload into JVM memory first.
A read may wait for the client.
Busker runs each handler on a virtual thread, a lightweight JVM thread managed by the request executor for that runtime generation.
Busker does not run the handler on the event loop thread that drives socket I/O.
The stream is single-use and closes when the request ends or the client disconnects.
Busker does not parse form, multipart, or other request data into parameters.
Add the Ring middleware that your application needs.
Set :max-request-entity-size to reject an upload above a chosen byte limit.
Response maps
An ordinary handler response uses the usual Ring shape:
{:status 200
:headers {"content-type" "text/plain; charset=utf-8" }
:body "ok" }
Ring defines status codes from 100 through 599.
Busker’s ordinary final response path requires an integer of at least 200 but does not enforce Ring’s upper bound.
Applications should keep ordinary final statuses in the Ring range of 200 through 599.
Busker treats an omitted :headers map as empty and an omitted or nil :body as an empty body.
If a handler returns nil, Busker sends a 404 response.
Response header names must be lowercase strings.
Busker does not normalize ordinary final response header names before passing them to libh2o, while HTTP/2 and HTTP/3 require lowercase field names.
Title-case response headers from an application or middleware are therefore unsupported for HTTP/2 and HTTP/3.
Busker sends scalar strings unchanged and applies str to other scalar values.
It emits each element of a sequential response header value as a separate field line, in sequence order.
This supports Ring vector values and the sequential Set-Cookie values produced by wrap-cookies and wrap-session.
Busker does not infer a response content-type; set that header in the response or with middleware.
Response body types
ring.core.protocols/StreamableResponseBody is Ring’s general protocol for writing a response body (and is not specific onlyto streaming responses).
When org.ring-clojure/ring-core-protocols is on the classpath, Busker accepts any value that satisfies this protocol.
Ring provides implementations for:
-
byte[] -
String -
clojure.lang.ISeq -
java.io.InputStream -
java.io.File -
nil
Applications may extend the protocol for another body type. Sequences and input streams can produce data as Busker reads them, as described in the next section.
Busker does not require ring-core-protocols.
Without it, Busker’s writer accepts byte[], String, java.nio.ByteBuffer, java.io.InputStream, nil, and sequential collections of fallback values.
The writer always encodes a String as UTF-8.
The writer writes a number as one byte using OutputStream.write(int).
The writer does not accept java.io.File or custom Ring protocol implementations.
Streaming responses
Sequences and input streams
The ISeq or InputStream are the "traditional" streaming response types.
A handler can return one of those as the :body ant the contents will be streamed to the client as it arrives.
Busker reads from that source and sends each part through a bounded response queue. The queue limits pending response data, and the writer waits for space when the client cannot keep up. Busker does not collect the whole body before the response starts (looking at you http-kit).
Response emitters
HTTP can do more than Ring’s single response map allows.
Informational responses and responses that continue after the handler returns do not fit that model.
Busker exposes those parts of HTTP through a ResponseEmitter for these HTTP features.
With an emitter, an application can:
-
send zero or more informational responses with statuses from 100 through 199, except
101 Switching Protocols; -
send
103 Early Hintswithlinkheaders so the client can start preloading resources; -
send
102 Processingwhile work continues; -
send one final status and header map, followed by body data over time;
-
tell Busker when the response is complete; and
-
run application cleanup through an
on-closecallback.
An informational response cannot contain a body. Each informational response has its own complete header map. After the final response is committed, its status and headers cannot change.
The emitter is available in the request under :ol.busker.request/emitter.
A handler uses it by returning it as the response body.
In the example below, the handler starts a virtual thread to send the informational response, final response, and body data.
(require ' [ol.busker.protocols :as response])
(defn my-handler
[{emitter :ol.busker.request/emitter }]
;; register an on close handler
(response/on-close emitter #(println "event stream closed" ))
;; do work in the background and emit events
(Thread/startVirtualThread
(fn []
(try
(response/emit! emitter
{:status 103
:headers {"link" "</events.css>; rel=preload; as=style" }})
(Thread/sleep 1000 )
(response/emit! emitter
{:status 200
:headers {"cache-control" "no-cache"
"content-type" "text/event-stream" }})
(doseq [event ["one" "two" "three" ]]
(response/emit! emitter (str "data: " event "\n\n" ))
(response/flush emitter))
(finally
(response/close emitter)))))
;; return the emitter right away with no additional data in the response map
{:body emitter})
The first emit! sends 103 Early Hints, allowing the client to preload the stylesheet.
The next emit! commits the final status and headers.
Later calls send body data, and flush makes buffered data available to the client.
Both emit! and flush may wait when the response queue is full, so the producer runs on a virtual thread.
The finally block closes the emitter whether the producer finishes normally or fails.
The zero-argument on-close callback runs when Busker learns that the response has ended.
Emitter calls do not pass through Ring middleware.
Set the emitted status, headers, and body data in the producer itself.
See the ResponseEmitter protocol reference for the complete API and close behavior.
Content length
Busker uses an explicit content-length response header when present.
Header lookup is case-insensitive, and the value must be a decimal string or number.
Busker removes that field from the ordinary header map and passes its numeric value to libh2o.
Without an explicit value, Busker calculates the byte length of String, byte[], java.io.File, and nil bodies.
A string length uses the charset in its content-type header, or UTF-8 when that header has no charset.
The fallback writer still encodes strings as UTF-8, so a non-UTF-8 content-type can produce the wrong calculated length when ring-core-protocols is absent.
Use UTF-8 for fallback string responses.
Applications can extend SizableResponseBody for another fixed-size type.
Busker leaves the length unknown for streams and sequences. libh2o then chooses the framing required by the negotiated HTTP version, such as chunked transfer encoding for HTTP/1.1.
HEAD requests
HTTP defines HEAD with the same semantics as GET except that the server sends no response content.
The response headers describe the representation that a corresponding GET request would receive.
For example, content-length on a HEAD response gives the size of the GET response body, not the zero bytes sent for HEAD.
Busker passes the request to the application as :request-method :head.
Busker does not suppress or discard a response body based on the request method.
A Busker application is responsible for returning a bodyless response to HEAD by omitting :body, setting it to nil, or applying a middlware such as wrap-head (see below).
If a handler returns a non-empty body for HEAD, wtill Busker send that body!
Ring provides the optional ring.middleware.head/wrap-head middleware for applications that want to reuse GET behavior.
For a HEAD request, wrap-head calls the wrapped handler with :request-method :get and then replaces the returned body with nil.
This lets an existing GET handler produce the status and headers for HEAD without duplicating handler logic.
Though this may not be for you if you want to explictly handler HEAD requests.
If middleware derives content-length from the GET body, it must calculate the length before wrap-head replaces the body with nil.
Otherwise Busker sees an empty body and calculates a length of zero.
A HEAD handler can instead provide an explicit content-length equal to the number of bytes that the corresponding GET response would send.
Middleware
Synchronous Ring middleware works when it produces the request and response forms supported on this page.
You can wrap the application handler yourself, or list wrappers in a dispatcher’s :middleware vector.
The configuration reference accepts a wrapper function, a qualified symbol, or [wrapper options].
Middleware responses must keep header names lowercase for HTTP/2 and HTTP/3.
Ring’s wrap-cookies and wrap-session emit a title-case Set-Cookie name, so their response path remains limited to HTTP/1.1 unless the application lowercases that name.