# ol.busker next Busker is your Clojure web server that plays live on the open web ## ol.busker.buffer-pool # ol.busker.buffer-pool ByteBuffer pooling response buffers This namespace provides a thread-safe pool that reuses ByteBuffer instances to minimize garbage collection pressure during high-throughput I/O operations. Buffers are organized into size-aligned buckets using a factor-based scheme. ## How It Works The pool groups buffers into buckets by capacity. When you request a buffer of size N bytes, the pool rounds up to the nearest multiple of the `factor` (default 2048), then looks in the corresponding bucket. If a matching buffer is available, it’s returned immediately. Otherwise, a fresh buffer is allocated. When you return a buffer, it’s placed back into its bucket if there’s room, subject to per-bucket and memory limits. Buffers that don’t align with bucket sizes (e.g., unusual capacities) are rejected and left for GC. ## Configuration Options * `:factor` (int, default 2048) - Bucket alignment step. Buffer capacities are rounded to multiples of this value. Larger factors reduce bucket count but may waste space; smaller factors increase bucket count but improve fit. * `:min-capacity` (int, default 0) - Minimum pooled capacity. Requests below this are rounded up. * `:max-capacity` (int, default 65536) - Maximum pooled capacity. Buffers larger than this are allocated but never pooled on return. * `:max-bucket-size` (int, default ~2x CPU cores) - Maximum buffers per bucket. Once a bucket reaches this limit, returned buffers are discarded. Use `-1` or `nil` for unbounded buckets. * `:max-heap-memory` (long, default unlimited) - Total bytes of heap buffers the pool will retain. Additional returns are rejected. * `:max-direct-memory` (long, default unlimited) - Total bytes of direct buffers the pool will retain. Additional returns are rejected. ## Example ```clojure (require '[ol.busker.buffer-pool :as pool]) (def p (pool/make-bytebuffer-pool {:factor 2048 :max-capacity 65536 :max-bucket-size 16})) ;; Borrow a 3000-byte direct buffer (rounds up to 4096). (let [buf (pool/borrow p 3000 true)] ;; Use buf... (pool/return p buf)) ;; returns true if pooled (pool/dispose p) ``` ## Thread Safety All operations are atomic and safe for concurrent access from multiple threads. ## BufferPool Protocol for thread-safe ByteBuffer pooling. _protocol_ [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker/buffer_pool.clj#L64-L102) ### borrow ```clojure (borrow pool size direct?) ``` Acquires a ByteBuffer with capacity at least `size` bytes. The actual capacity may be larger due to bucket alignment. If `size` exceeds the pool's `:max-capacity`, a fresh buffer is allocated but won't be pooled on return. Arguments: - `size` (long) - Minimum required capacity in bytes - `direct?` (boolean) - `true` for direct (off-heap) buffer, `false` for heap Returns: - `java.nio.ByteBuffer` with `capacity >= size` and `position` at 0 --- ### return ```clojure (return pool buffer) ``` Returns a ByteBuffer to the pool for potential reuse. The buffer is only pooled if: - Its capacity aligns with a bucket size (multiple of `:factor`) - The bucket isn't full (under `:max-bucket-size`) - Pool memory limits aren't exceeded (`:max-heap-memory` / `:max-direct-memory`) Arguments: - `buffer` (ByteBuffer) - Buffer to return, or `nil` (ignored) Returns: - `boolean` - `true` if the buffer was accepted into the pool, `false` if rejected --- ### dispose ```clojure (dispose pool) ``` Clears all pooled buffers and releases resources. After calling `dispose`, `borrow` will still allocate fresh buffers, but `return` will reject all buffers. Call this during application shutdown. Returns: - `:disposed` keyword --- ## make-bytebuffer-pool ```clojure (make-bytebuffer-pool opts) ``` Create a new buffer pool instance. opts may include: :min-capacity (int) minimum pooled buffer capacity (default 0) :factor (int) capacity step/bucket factor (default 2048) :max-capacity (int) maximum pooled capacity (default 65536) :max-bucket-size (int) max buffers per bucket (-1 or nil for unbounded; default ~2x CPU) :max-heap-memory (long) pooled heap-bytes cap (0 or nil for heuristic/unlimited) :max-direct-memory (long) pooled direct-bytes cap (0 or nil for heuristic/unlimited) Returns a value that satisfies BufferPool. [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker/buffer_pool.clj#L222-L238) ## ol.busker.middleware # ol.busker.middleware Ring middleware for common HTTP handling patterns. ## wrap-reject-early-data ```clojure (wrap-reject-early-data handler) (wrap-reject-early-data handler {:keys [methods on-reject] :or {methods (complement idempotent-methods)}}) ``` Middleware that rejects non-idempotent requests arriving via 0-RTT. 0-RTT data can be replayed by attackers. This middleware returns HTTP 425 Too Early for POST, PUT, DELETE, PATCH requests that arrive before the TLS handshake completes. Options: :methods - set of methods to reject (default: all except GET, HEAD, OPTIONS, TRACE) :on-reject - fn called with request when rejecting (for logging) Example: (wrap-reject-early-data handler) (wrap-reject-early-data handler {:on-reject #(log/warn "Rejected early" %)}) [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker/middleware.clj#L10-L38) ## ol.busker.protocols # ol.busker.protocols ## SizableResponseBody _protocol_ [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker/protocols.clj#L5-L10) ### body-size-in-bytes ```clojure (body-size-in-bytes body ring-response) ``` Return the number of bytes that `body` will require when it is serialized as a response body. If the number of bytes cannot be ascertained, nil is returned. The ring-response is used for determining supporting information, such as the charset in the case of String bodies. --- ## ResponseEmitter Controls an asynchronous server-to-client stream for one HTTP response. Use this protocol for Server-Sent Events (SSE), response streaming, and long polling. The stream closes when application code calls <>, a final response completes, or libh2o reports request termination. _protocol_ [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker/protocols.clj#L12-L92) ### open? ```clojure (open? this) ``` Returns true while the emitter accepts writes. <> changes this value to false before it dispatches close callbacks. --- ### committed? ```clojure (committed? this) ``` Returns true after a final response is committed. Writing body data before a final response commits status 200. Accepted informational responses do not commit the final response. --- ### emit! ```clojure (emit! this data) (emit! this data opts) ``` Writes `data` to the client. Returns true when the writer accepts the data. Returns false when the native writer has stopped. Returns nil when a response map is ignored because another final response is already committed. The call may throw when `data` is invalid or the stream closes during a write. Emitter writes do not pass through Ring middleware. `data` may be a response map or body data. A response map follows these rules: * Statuses 100 through 199 except 101 are informational and reject `:body` * Status 101 is unsupported * A status of at least 200 commits the only final response * Header names must be lowercase strings, with one value per name Body data before a final response commits status 200. With `:close-after? false`, chunks may have these types: * `nil`, `byte[]`, `Number`, or `String` * `java.nio.ByteBuffer` or `java.io.InputStream` * Sequential collections of these types With `:close-after? true`, Busker writes a complete response body. When `ring-core-protocols` is present, the body must satisfy `ring.core.protocols/StreamableResponseBody`. Without it, Busker uses its fallback chunk types. Busker calculates `content-length` when the body size is known, but it does not infer `content-type`. Options: | | | | --- | --- | | key | description | | `:close-after?` | Close the stream after writing `data` (default `false`) | --- ### flush ```clojure (flush this) ``` Flushes buffered response data to the client. This call may throw when the stream is closing or closed. --- ### close ```clojure (close this) ``` Closes the stream and begins callback delivery. Returns true only for the call that claims an open emitter. Later calls return false. Registered callbacks run after the writer is closed. --- ### on-close ```clojure (on-close this callback) ``` Registers a zero-argument `callback` to run when the emitter closes. Callbacks run exactly once on virtual threads after explicit close, final response completion, or request termination reported by libh2o. Busker cannot run the callback for a client disconnect until libh2o reports it. An idle HTTP/1.1 client can disconnect without libh2o noticing right away. If the application sends no more data, the callback may wait until a later write or explicit close. Graceful server shutdown waits for the active stream rather than closing it. For HTTP/2 and HTTP/3, the callback runs after an idle client disconnect without waiting for another application write. Callbacks registered before termination run in registration order, and an earlier callback failure does not suppress later callbacks. A callback registered after termination runs promptly on a virtual thread. ## ol.busker # ol.busker Public lifecycle API for Busker. See [Configuration](configuration.adoc) for the map accepted by [`start!`](#start!) and [`reload!`](#reload!). ## start! ```clojure (start! config) ``` Start a Busker server from `config` and return an opaque server handle. See [Configuration](configuration.adoc) for the config shape. [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker.clj#L9-L14) --- ## reload! ```clojure (reload! server config) (reload! server config opts) ``` Compile and activate a new runtime snapshot for `server` from `config`. See [Configuration](configuration.adoc) for the config shape. [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker.clj#L16-L23) --- ## stop! ```clojure (stop! server) ``` Synchronously stop `server`. [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker.clj#L25-L28) --- ## state ```clojure (state server) ``` Return pure runtime state for `server`. The returned state includes the normalized config snapshot. See [Configuration](configuration.adoc) for that config shape. [source,window=_blank](https://github.com/outskirtslabs/busker/blob/main/src/main/clojure/ol/busker.clj#L30-L36) ## Changelog # Changelog All notable changes to this project will be documented in this file. This project uses [**Break Versioning**](https://www.taoensso.com/break-versioning). ## Unreleased ## `v0.0.1` (2026-XX-XX) We haven’t quite got here yet.. Please report any problems and let me know if anything is unclear or inconvenient. Thank you. ## Configuration # Configuration Busker is configured with an ordinary Clojure map. The same config shape is used when starting a server with [`ol.busker/start!`](api/ol-busker.adoc#start-BANG-) and when changing a running server with [`ol.busker/reload!`](api/ol-busker.adoc#reload-BANG-). You can write that map directly or generate it from environment variables, files, a database, or other application state. It’s just data. When Busker starts or reloads, it turns your config map into a normalized config _snapshot_. A snapshot is the effective config after defaults are applied. A running server is backed by a runtime _generation_. A generation is the set of runtime resources built from one snapshot. For example, it includes open sockets, worker threads, native protocol state, handlers, and TLS automation state. On reload, Busker builds a new generation while the current one keeps serving traffic. If the new generation starts successfully, new connections and requests move to it. The old generation stops accepting new work and drains the connections and requests it is already handling. If the new generation fails to start, the old generation keeps serving traffic. A minimal HTTP config with HTTP/1.1, HTTP/2, and automatic gzip/zstd/brotli compression is: ```clojure {:entrypoints {:http {:bind "127.0.0.1:8080" :tls false}} :dispatch [{:handler (fn [_req] {:status 200 :headers {"content-type" "text/plain"} :body "ok"})}]} ``` That’s how you would use Busker when coming from traditional Clojure ring-adapters like Jetty or http-kit. A minimal HTTP+HTTPS config with HTTP/1.1, HTTP/2, HTTP/3, automatic gzip/zstd/brotli compression, and certificates obtained and renewed automatically is: ```clojure {:tls {:certificates {:manage ["example.com"]}} :entrypoints {:http {:bind ":80" :tls false} :https {:bind ":443"}} :dispatch [{:handler (fn [_req] {:status 200 :headers {"content-type" "text/plain"} :body "ok"})}]} ``` Note the addition of the `example.com` domain, that is required so Busker can manage the certificate lifecyle for you. An _entrypoint_ names one or more network listeners and the protocols they accept. A _dispatcher_ is one ordered step in the request pipeline that can match, transform, or handle requests for one or more entrypoints. In conventional Clojure programs there is usually only a single dispatcher: the Ring handler. Busker accepts the following top-level config keys. `:entrypoints` and `:dispatch` are required. Other keys are optional and either use their documented default or remain unset. | | | | --- | --- | | key | value | | `:entrypoints` | Map of entrypoint ids to entrypoint maps (see below). | | `:dispatch` | Ordered vector of dispatcher maps (see below). | | `:tls` | Global TLS, certificate, ACME, and session ticket config (see below). | | `:n-workers` | Positive integer event loop worker count, default `1`. | | `:buffer-pool` | Response buffering pool implementing [`BufferPool`](api/ol-busker-buffer-pool.adoc#BufferPool). | | `:max-connections` | Positive integer connection cap, default `1024`. | | `:output-buffer-size` | Response aggregation bytes, default `32768`; `0` disables buffering. | | `:server-name` | Server header value, default `"ol.busker/dev"`. | | `:proxy-status-identity` | Optional Proxy-Status identity. | | `:max-request-entity-size` | Optional positive integer request body byte limit. | | `:max-delegations` | Optional positive integer internal delegation limit. | | `:max-reprocesses` | Optional positive integer internal reprocess limit. | | `:handshake-timeout` | Optional TLS handshake timeout in milliseconds. | | `:max-spare-pipes` | Optional non-negative idle pipe cache limit. | | `:http1-req-timeout` | Optional HTTP/1.1 request timeout in milliseconds. | | `:http1-req-io-timeout` | Optional HTTP/1.1 request I/O timeout in milliseconds. | | `:http1-upgrade?` | Allow h2c upgrade, default `true`. | | `:http2-idle-timeout` | Optional HTTP/2 idle timeout in milliseconds. | | `:http2-graceful-shutdown-timeout` | Optional HTTP/2 graceful shutdown timeout in milliseconds. | | `:http2-max-streams` | Optional positive integer concurrent HTTP/2 stream limit. | | `:http2-max-requests` | Optional positive integer concurrent HTTP/2 request limit. | | `:http2-max-streaming-requests` | Optional positive integer streaming request limit. | | `:http2-max-priority-streams` | Optional positive integer priority stream tracking limit. | | `:http2-stream-window-size` | Optional positive integer per-stream window bytes. | | `:http2-dos-delay` | Optional suspicious behavior delay in milliseconds. | | `:http3-idle-timeout` | Optional HTTP/3 idle timeout in milliseconds. | | `:http3-graceful-shutdown-timeout` | Optional HTTP/3 graceful shutdown timeout in milliseconds. | | `:http3-stream-window-size` | Optional positive integer per-stream window bytes. | | `:http3-ack-frequency` | Optional non-negative ACK frequency; `0` uses the QUIC default. | | `:compress?` | Enable response compression, default `true`. | | `:compress-min-size` | Minimum response bytes for compression, default `100`. | | `:compress-gzip-level` | Gzip level `0` through `9`, default `1`. | | `:compress-brotli-level` | Brotli level `0` through `11`, default `1`. | | `:compress-zstd-level` | Zstandard compression level, default `3`. | ## Entrypoints (`:entrypoints`) Entrypoints describe how traffic enters Busker. Each entrypoint gives a stable id to one or more bind addresses and the protocol and TLS settings used by those listeners. The id exists so other config sections can refer to the entrypoint without repeating host and port details. `:entrypoints` in the top-level config is a map from keyword entrypoint ids to maps: ```clojure {:entrypoints {:plain {:bind ["127.0.0.1:8080" "[::1]:8080"] :tls false} :secure {:bind ":8443"}}} ``` The ids (`:plain` and `:secure` above) are arbitrary user-defined keywords. * `:bind` is a string or non-empty vector of strings. TCP and UDP bind forms are `":8080"`, `"host:8080"`, and `"[::1]:8080"`. Unix domain sockets use pathname form `"unix:/path.sock"` or abstract namespace form `"unix:@abstract-name"`. * `:tls` is `false` for cleartext or a map to enable TLS. A TLS entrypoint requires top-level `:tls :certificates :load` or `:tls :certificates :manage`. Entrypoint-local TLS settings use the same keys as top-level `:tls`, but certificate material remains top-level. * `:http1?` and `:http2?` default to `true`. * `:http3?` defaults to `true` when TLS is enabled and `false` otherwise. HTTP/3 requires TLS and is not supported on Unix domain sockets. ## Dispatchers (`:dispatch`) Dispatchers describe what Busker does with each request after it arrives. Busker evaluates them in order, optionally restricting them by entrypoint, match predicate, group, middleware, and terminal behavior. `:dispatch` in the top-level config is an ordered vector of dispatcher maps. For conventional Clojure programs, the most common shape is a single dispatcher that points at one Ring handler. Start with this unless one server needs multiple entrypoint-specific or staged request pipelines. ```clojure {:dispatch [{:handler your-ring-handler}]} ``` Advanced use-cases can add entrypoint filters, match predicates, mutually exclusive groups, middleware, and terminal behavior. This is useful when using Busker as an application router or reverse-proxy-style edge, where one server routes different traffic classes before handing each request to the appropriate Ring handler or upstream: ```clojure {:dispatch [{:entrypoints #{:secure} :group :site :match my.app/api-request? :middleware [[my.app/wrap-audit {:mode :strict}] my.app/wrap-auth] :handler my.app/handler :terminal? true}]} ``` * `:entrypoints` - Optional set of entrypoint ids where the dispatcher may run (default: all entrypoints). * `:group` - Optional key that allows only the first matching dispatcher in the group (default: none). * `:match` - Optional predicate of the current request (default: match every request). * `:middleware` - Vector of middleware functions, symbols, or `[middleware opts]` entries (default: `[]`). * `:handler` - Handler function for the dispatcher (default: `{:status 200}`). * `:terminal?` - Return the handler result immediately when true (default: `false`). Function references in `:match`, `:handler`, and `:middleware` may be direct functions or qualified symbols. Middleware entries are Ring-style wrapper functions. `[wrap opts]` calls `(wrap handler opts)`, and a bare `wrap` calls `(wrap handler)`. Non-terminal dispatcher results become the current value for later dispatchers, so dispatchers can act as request transforms before a terminal handler returns a Ring response. ## TLS (`:tls`, top-level) The top-level TLS config describes how Busker terminates encrypted traffic, finds certificate material, and runs certificate automation for TLS entrypoints. `:tls` in the top-level config is a map of options: * `:certificates` - Map of certificate sources and managed names; see below (default: none). * `:storage` - `Storage` instance or storage factory map; see Storage factory map below (default: Clave file storage for managed certificates). * `:issuers` - Vector of issuer maps; see Issuer maps below (default: Let’s Encrypt production). * `:issuer-selection` - ACME issuer choice strategy, `:in-order` or `:shuffle` (default: `:in-order`). * `:key-type` - Certificate key algorithm (default: `:p256`). * `:key-reuse` - Reuse private keys when renewing certificates (default: `false`). * `:cache-capacity` - Positive integer certificate cache capacity (default: `1000`). * `:solvers` - ACME challenge solver map; see Solvers below (default: Busker-managed `:http-01` for managed certificates). * `:ocsp` - OCSP option map; see OCSP map below (default: none). * `:config-fn` - Function or qualified symbol returning per-subject/per-domain overrides (default: none). * `:http-client` - HTTP client option map for ACME requests (default: none). * `:tls-compatibility-mode` - TLS cipher/protocol preset, `:modern` or `:intermediate` (default: `:modern`). * `:session-tickets` - Session ticket map; see Session ticket map below (default: enabled in memory). ### Certificates map (`:tls :certificates`) The `:certificates` map contains: * `:load` - Vector of static certificate source maps (default: `[]`). * `:type` - Source type, either `:pem` or `:folder` (required). * `:cert-file` - PEM certificate chain path for `:pem` sources. * `:key-file` - PEM private key path for `:pem` sources. * `:path` - Directory path for `:folder` sources. * `:manage` - Vector of subject names (domain names) Busker should obtain and renew automatically (default: `[]`). * Entries are strings such as `"example.com"` or `"*.example.com"`. Static sources in `:load` are read during startup and reload. Busker extracts subject names from each certificate and uses SNI to choose a matching static certificate during the TLS handshake. If there is no SNI match, Busker falls back to the first loaded static certificate. Subject names in `:manage` are handed to Clave certificate automation. Busker starts the automation machinery during startup and reload, stores issued certificates in TLS storage, and uses SNI to look them up during TLS handshakes. Certificate lifecycle work such as obtain, renewal, and OCSP maintenance operates in the background. **Obtain certificates automatically** ```clojure {:tls {:certificates {:manage ["example.com" "www.example.com"]}}} ``` **Bring your own certificates** ```clojure {:tls {:certificates {:load [{:type :pem :cert-file "example.com/fullchain.pem" :key-file "example.com/privkey.pem"}]}}} ``` ### Storage factory map (`:tls :storage`) The `:storage` key may be a [`ol.clave.storage/Storage`](next@ol.clave::api/ol-clave-storage.adoc#Storage) instance or a factory map. When omitted, managed certificate automation uses [`ol.clave.storage.file/file-storage`](next@ol.clave::api/ol-clave-storage-file.adoc#file-storage) with its no-arg default root. The Clave default is to use `/ol.clave`, where `` is `$STATE_DIRECTORY` (for systemd usage) or `$XDG_DATA_HOME`. While often not necessary, you can configure this explicitly. When used as a map `:storage` contains: * `:factory` - Function or qualified symbol returning an [`ol.clave.storage/Storage`](next@ol.clave::api/ol-clave-storage.adoc#Storage) implementation (required). * factory-specific keys - Options passed to the factory (default: none). For the built-in filesystem storage factory, see [`ol.clave.storage.file/file-storage`](next@ol.clave::api/ol-clave-storage-file.adoc#file-storage). Its options include `:root`, a root directory string or `java.nio.file.Path`. ### Issuer maps (`:tls :issuers`) The `:issuers` vector contains maps: * `:directory-url` - ACME directory URL (required). * `:email` - Contact email for certificate notifications (default: none). * `:eab` - External account binding credentials for restricted CAs (default: none). ### OCSP map (`:tls :ocsp`) The `:ocsp` map contains: * `:enabled?` - Enable OCSP stapling (default: `true`). * `:must-staple?` - Require OCSP must-staple in certificates (default: `false`). ### Session ticket map (`:tls :session-tickets`) The `:session-tickets` map contains: * `:disabled?` - Disable session ticket resumption when true (default: `false`). * `:persistence` - Session ticket persistence mode, `:memory` or `:storage` (default: `:memory`). * `:storage` persistence requires top-level `:tls :storage`. * `:max-keys` - Maximum number of ticket keys retained in rotation (default: `4`). * `:lifetime-seconds` - TLS session ticket lifetime in seconds (default: `86400`). ### Solvers (`:tls :solvers`) Solvers describe how ACME challenge records are presented and cleaned up while Busker obtains managed certificates. Busker provides an integrated `:http-01` solver by default. It serves `/.well-known/acme-challenge/...` through the Ring handler pipeline. Busker does not provide `:dns-01` or `:tls-alpn-01` by default; add those to `:solvers` when needed. The `:solvers` map contains additional Clave solver entries keyed by challenge type: * `:http-01` - HTTP challenge solver for public HTTP validation. Busker always replaces the value of this key with its integrated solver. * `:dns-01` - DNS challenge solver for DNS-provider validation. This is required for wildcard names such as `"*.example.com"`. * `:tls-alpn-01` - TLS ALPN challenge solver for port 443 validation. To write a custom solver, see [`ol.clave.certificate`](next@ol.clave::api/ol-clave-certificate.adoc) and [`ol.clave.certificate/validate-solvers`](next@ol.clave::api/ol-clave-certificate.adoc#validate-solvers). This example adds `:dns-01`, while Busker still adds its integrated `:http-01` solver: ```clojure {:tls {:certificates {:manage ["example.com" "*.example.com"]} :solvers {:dns-01 my-dns-solver}}} ``` ## Serve HTTPS and HTTP/3 # Serve HTTPS and HTTP/3 This guide adds managed certificates, HTTPS, and HTTP/3 to the plain application from [Your first Busker server](tutorial-first-server.adoc). The Ring handler and its `start!` and `stop!` functions stay the same. ## Prepare the host Use a real domain such as `app.example.com` and meet these requirements before starting Busker: * Create an `A` record that points the domain to the server’s public IPv4 address * Create an `AAAA` record only if the server accepts public IPv6 traffic * Allow inbound TCP traffic on ports 80 and 443 * Allow inbound UDP traffic on port 443 * Forward all three ports to the Busker host if it is behind NAT * Keep ACME account and certificate data in durable, private storage * Install a curl build with HTTP/3 support if you want to run the HTTP/3 check Busker uses the HTTP-01 ACME challenge by default, so the certificate authority must reach the domain on TCP port 80. Keep TCP port 80 reachable after the first certificate is issued so later renewals can complete. HTTPS uses TCP port 443 for HTTP/1.1 and HTTP/2, while HTTP/3 uses UDP port 443. Opening TCP 443 does not open UDP 443. On Linux, an ordinary process cannot normally bind ports 80 and 443. The startup instructions below keep the JVM unprivileged by granting permission only to the systemd service or by mapping container ports. ## Choose how to start Busker Choose one of these ways to start Busker. The numbers are used throughout the rest of the guide. 1. Use a systemd service for an application that should start at boot and keep running 2. Use `systemd-run` to try the server from a terminal without installing a service 3. Use Docker or Podman when the application runs in a container Options 1 and 2 let Busker bind public ports 80 and 443 directly. Option 3 keeps Busker on unprivileged ports inside the container and lets the container runtime publish the public ports. ## Add managed TLS Replace the plain `config` map from the tutorial with this map. Change `app.example.com` to your domain. ```clojure {:tls {:certificates {:manage ["app.example.com"]} :storage {:factory 'ol.clave.storage.file/file-storage :root (or (System/getenv "STATE_DIRECTORY") (str (System/getProperty "user.home") "/.local/state/my-app"))}} :entrypoints {:http {:bind ":80" :tls false} :https {:bind ":443"}} :dispatch [{:handler handler}]} ``` If you chose option 3, replace only the `:entrypoints` value with this one: ```clojure :entrypoints {:http {:bind ":8080" :tls false} :https {:bind ":8443"}} ``` The `:http` entrypoint serves the ACME HTTP-01 challenge and accepts ordinary HTTP traffic. The `:https` entrypoint enables TLS and HTTP/3 by default because its `:tls` value defaults to the modern TLS settings. Busker obtains and renews the managed certificate with Let’s Encrypt. The storage root uses the directory supplied by systemd through `STATE_DIRECTORY`. Outside systemd it uses `.local/state/my-app` under the current user’s home directory. Keep this directory between deployments and back it up with the rest of the service’s durable state. Repeated issuance from an empty directory can reach certificate authority rate limits. See [Certificates](configuration.adoc#_certificates_map_tls_certificates) for managed certificate options. If you already have a certificate and private key, use the [static certificate settings](configuration.adoc#_certificates_map_tls_certificates) instead of adding another full example here. ## Add a process entry point Options 1 and 3 need an entry point that starts Busker, remains running, and stops the server when the JVM shuts down. If you chose option 2, continue to [Start the server](#start-the-server) because the REPL will keep the process alive. Add this function to `main.clj`: ```clojure (defn -main [& _] (start!) (.addShutdownHook (Runtime/getRuntime) (Thread. stop!)) @(promise)) ``` If your application already has a production entry point that calls `start!` and installs a shutdown hook, keep using it. ## Start the server Use the numbered section that matches your earlier choice. Do not run the JVM as root and do not add capabilities to the Java binary. ### 1. Run it as a systemd service Create the `my-app` service account before loading the unit if your deployment does not already provide it. Create `/etc/systemd/system/my-app.service` with the following content. Replace the working directory and Clojure executable with the paths used by your installation. Run `command -v clojure` if you need to find the executable. ```ini [Unit] Description=My Busker application Wants=network-online.target After=network-online.target [Service] Type=simple User=my-app Group=my-app WorkingDirectory=/opt/my-app ExecStart=/usr/local/bin/clojure -J--enable-native-access=ALL-UNNAMED -M -m main AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=yes StateDirectory=my-app StateDirectoryMode=0700 Restart=on-failure [Install] WantedBy=multi-user.target ``` `User` and `Group` keep the JVM under the unprivileged `my-app` account. `AmbientCapabilities` permits this service to bind low ports, while `CapabilityBoundingSet` excludes unrelated capabilities. `NoNewPrivileges` prevents the process from gaining more privileges later. `StateDirectory` creates `/var/lib/my-app`, makes it writable by the service account, and supplies that path through `STATE_DIRECTORY`. Load the unit and start the service: ```bash sudo systemctl daemon-reload sudo systemctl enable --now my-app.service sudo systemctl status my-app.service ``` Busker is now running under the `my-app` account and listening on the public ports. Continue to [Wait for the certificate](#wait-for-the-certificate). ### 2. Try it with `systemd-run` `systemd-run` can create a temporary service for the current terminal session. This lets the REPL bind ports 80 and 443 without running the REPL as root. The command uses your user and group IDs, preserves the environment needed by the Clojure CLI, and opens an interactive terminal: ```bash sudo systemd-run \ --unit=my-app-cli \ --pty \ --wait \ --collect \ --uid="$(id -u)" \ --gid="$(id -g)" \ --working-directory="$PWD" \ --setenv=HOME="$HOME" \ --setenv=PATH="$PATH" \ --property=StateDirectory="my-app-cli-$(id -u)" \ --property=AmbientCapabilities=CAP_NET_BIND_SERVICE \ --property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE \ --property=NoNewPrivileges=yes \ "$(command -v clojure)" -M:repl ``` The `sudo` command authorizes systemd to create the temporary service. The Clojure process itself runs with your user and group IDs. Start Busker from the REPL: ```clojure (require '[main :as app]) (app/start!) ``` Keep this REPL open and continue to [Wait for the certificate](#wait-for-the-certificate). ### 3. Run it with Docker or Podman Use the unprivileged entrypoint ports shown for option 3 above. Create a storage directory owned by the account that starts the container: ```bash mkdir -p "$HOME/.local/state/my-app" chmod 0700 "$HOME/.local/state/my-app" ``` Add these flags to `docker run` or `podman run`: ```bash --name my-app \ --user "$(id -u):$(id -g)" \ --cap-drop ALL \ --env STATE_DIRECTORY=/var/lib/my-app \ --volume "$HOME/.local/state/my-app:/var/lib/my-app" \ --publish 80:8080/tcp \ --publish 443:8443/tcp \ --publish 443:8443/udp ``` Busker needs no capabilities inside the container because it binds ports above 1024. The runtime publishes TCP 80, TCP 443, and UDP 443 on the host. Do not omit the UDP mapping because HTTP/3 cannot use the TCP mapping. On an SELinux host, add the appropriate relabel option to the storage mount. A rootless Docker or Podman setup may reject host ports 80 and 443 when the host keeps its default unprivileged-port policy. Follow the container runtime’s documentation for privileged host ports if that happens. After the container starts, continue to [Wait for the certificate](#wait-for-the-certificate). ## Wait for the certificate Certificate issuance runs in the background. The first TLS connection can fail until the certificate authority has validated the domain and Busker has stored the new certificate. Keep the server running while validation completes. Once the domain has a certificate, continue with the protocol checks. ## Verify each HTTP version First confirm that your curl build lists `HTTP3` in its features: ```bash curl --version ``` Run each request separately and check the printed version: ```bash curl --http1.1 -sS -o /dev/null -w 'HTTP/%{http_version}\n' https://app.example.com/ curl --http2 -sS -o /dev/null -w 'HTTP/%{http_version}\n' https://app.example.com/ curl --http3-only -sS -o /dev/null -w 'HTTP/%{http_version}\n' https://app.example.com/ ``` The commands should print `HTTP/1.1`, `HTTP/2`, and `HTTP/3` in that order. Use `--http3-only` for the last check because `--http3` may fall back to TCP and hide a blocked UDP route. ## Stop a test server After the checks finish, stop the server if it should not remain running: 1. Leave option 1 running, or stop it with `sudo systemctl stop my-app.service` 2. For option 2, run `(app/stop!)`, then press Ctrl-D to leave the REPL and remove the temporary service 3. Stop option 3 with `docker stop my-app` or `podman stop my-app` ## Check startup failures For a permanent systemd service, inspect its log and current state: ```bash sudo systemctl status my-app.service sudo journalctl -u my-app.service systemctl show my-app.service \ -p User \ -p AmbientCapabilities \ -p CapabilityBoundingSet ``` A low-port permission error means the service did not receive `CAP_NET_BIND_SERVICE`. An `Address already in use` error means another process is already using one of the TCP or UDP ports. A storage permission error means the service account or container user cannot write to the configured state directory. ## Check failed certificate issuance If Busker does not obtain a certificate, check these conditions: * The domain’s `A` record points to the public IPv4 address of this server * Any published `AAAA` record points to a working IPv6 listener on this server * Public TCP port 80 reaches Busker rather than another service or an unconfigured proxy * The service can write to the configured storage directory * The domain is not behind a proxy that answers the ACME challenge instead of Busker * Certificate authority rate limits have not blocked another issuance attempt Test the public address from a machine outside the server’s network. A request from the server itself can succeed even when a firewall or NAT rule blocks public traffic. ## Check failed HTTP/3 connections If HTTP/1.1 and HTTP/2 work but HTTP/3 fails, check these conditions: * Public UDP port 443 is allowed by the host firewall, cloud firewall, and network firewall * NAT forwards UDP 443 as well as TCP 443 to the Busker host * A container deployment publishes `443:8443/udp` * No proxy or load balancer in front of Busker drops QUIC traffic * The curl build reports HTTP/3 support * The test uses `--http3-only` and the same hostname covered by the certificate HTTP/3 uses QUIC over UDP, so a working HTTPS connection over TCP does not prove that the UDP route works. ## ol.busker # ol.busker > Busker is a Clojure web server that plays live on the open web ![busker docs](https://img.shields.io/badge/ol-docs-orange.svg) ![status: experimental](https://img.shields.io/badge/status-experimental-red.svg) ![built with nixbot](https://img.shields.io/badge/CI-builds-brightgreen,link=) `ol.busker` is a Clojure web server built on [libh2o](https://github.com/h2o/h2o) (the web server core that powers Fastly’s global CDN). Busker provides: * modern HTTP support, including HTTP/1.1, HTTP/2, HTTP/3, 103 Early Hints, 0-RTT TLS, etc. * automatic HTTPS certificate obtaining and renewal * simplified deployment by letting you deploy an uberjar directly with `systemd`, without nginx, Caddy, or Docker * predictable performance under load with production-ready defaults ## Platform requirements Busker requires JDK 25 or later. Pre-built jars containing native binaries are available for Linux and macOS on aarch64 and x86-64. Project status: **[Experimental](https://docs.outskirtslabs.com/open-source-vital-signs#experimental)**. ## Usage Add the Busker git dependency to `deps.edn`: ```clojure com.outskirtslabs/busker {:git/url "https://github.com/outskirtslabs/busker" :git/sha "0e2ead5507b03d18fa94c01647a37188b37b25f6"} ``` When using Busker as a git dependency, run `clj -X:deps prep` after bumping the git sha. Add at least one native dependency for your target platform: ```clojure ;; Choose at least one of the following. com.outskirtslabs.busker/linux-x86-64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/linux-aarch64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/macos-x86-64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/macos-aarch64 {:mvn/version "0.0.4"} ``` If you include multiple native dependencies, Busker chooses the one matching the current platform. Including extra native dependencies increases uberjar size. ### Bundled native dependencies Busker statically links these security-sensitive upstream dependencies into its native shim jars. | | | | --- | --- | | Dependency | Upstream date | | [libh2o](https://github.com/h2o/h2o) | [2026-07-20 (3a5d2cb)](https://github.com/h2o/h2o/commit/3a5d2cb898bdb54795f060be7aba478912f65bb0) | | [BoringSSL](https://github.com/google/boringssl) | [2026-08-03 (30a26e9)](https://github.com/google/boringssl/commit/30a26e970e14f9d943e3961de49bee5cc7e032d3) | ## Examples See the example projects in [`examples/`](https://github.com/outskirtslabs/busker/tree/main/examples): * [`quickstart`](https://github.com/outskirtslabs/busker/tree/main/examples/quickstart) shows a small server with HTTP, TLS, hello, echo, and streaming routes * [`early-hints`](https://github.com/outskirtslabs/busker/tree/main/examples/early-hints) shows how to emit `103 Early Hints` before a final response * [`sse`](https://github.com/outskirtslabs/busker/tree/main/examples/sse) shows a long-lived Server Sent Events stream with optional Brotli compression ## Documentation * [Docs](https://docs.outskirtslabs.com/ol.busker/next/) * [API Reference](https://docs.outskirtslabs.com/ol.busker/next/api) * [Support via GitHub Issues](https://github.com/outskirtslabs/busker/issues) ## Roadmap The project description above is aspirational. Busker is still a work in progress. See [Roadmap](roadmap.adoc) for current roadmap ideas. ## Changelog See [Changelog](changelog.adoc) for notable changes. ## Security See [Security](security.adoc) for security reporting and policy links. ## License Busker is distributed under the [EUPL-1.2](https://spdx.org/licenses/EUPL-1.2.html). Copyright (C) 2025-2026 Casey Link. Some files included in this project and in binary distributions, including JAR files on Clojars and GitHub releases, are from third-party sources and retain their original licenses as indicated in [NOTICE](https://github.com/outskirtslabs/busker/blob/main/NOTICE). ## Ring compatibility # 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 | | `:server-port` | `Integer` | Port selected from the request authority. | | `:server-name` | `String` | Host selected from the request authority. | | `:remote-addr` | `String` | Client address, usually numeric for TCP and possibly nonnumeric or empty for Unix sockets or lookup failures. | | `:uri` | `String` | Path without the query string. | | `:query-string` | `String` or `nil` | Text after `?`, without the `?`. | | `:scheme` | `Keyword` | `:http` or `:https`. | | `:request-method` | `Keyword` | Lowercase method such as `:get`, `:head`, or `:post`. | | `:protocol` | `String` | `"HTTP/1.1"`, `"HTTP/2.0"`, or `"HTTP/3.0"`. | | `:headers` | `Map` of `String` to `String`, or `nil` | Header names are lowercase. | | `:body` | `java.io.InputStream` or `nil` | 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 | | `:ol.busker/entrypoint` | `Keyword` | The inferred [entrypoint](configuration.adoc), a named listener group. | | `:ol.busker/early-data?` | `Boolean` | True when TLS accepted the request as 0-RTT early data. | | `:ol.busker.request/emitter` | [`ResponseEmitter`](api/ol-busker-protocols.adoc#ResponseEmitter) | 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`](api/ol-busker-middleware.adoc#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`](configuration.adoc) to reject an upload above a chosen byte limit. ## Response maps An ordinary handler response uses the usual Ring shape: ```clojure {: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`](api/ol-busker-protocols.adoc#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 Hints` with `link` headers so the client can start preloading resources; * send `102 Processing` while 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-close` callback. 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. ```clojure (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" "; 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](api/ol-busker-protocols.adoc#ResponseEmitter) 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`](api/ol-busker-protocols.adoc#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](configuration.adoc) 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. ## Roadmap # Roadmap ## Backlog Not in order: * [x] Dynamic runtime configuration * [x] Dynamic TLS certificate selection based on SNI for multi-domain hosting on a single port * [x] TLS STEK and pluggable ticket store * [x] 0-RTT TLS * [ ] Native file handler with compression to bypass the JVM for files on disk * [ ] Metrics and signals without OpenTelemetry, or with minimal OpenTelemetry support * [ ] Cross-process TLS ticket resumption tests * [ ] Reverse proxying * [ ] Standalone operation mode with a config file * [ ] Zero-downtime deployments with `systemd` * [ ] GraalVM native-image support * [ ] Configurable congestion control to allow selection of Reno, Cubic, or BBR instead of using quicly defaults * [ ] Connection keep-alive configuration with explicit interval and timeout settings instead of implicit ACK frequency * [ ] User-supplied TLS certificate callback API * [ ] QUIC datagram support for RFC 9221 unreliable datagram frames, useful for WebRTC over HTTP/3 and gaming protocols * [ ] Built-in `.ts.net` support ## Small tasks * [x] Implement request cleanup and reaping when clients disconnect early, including interruption of body and response streams * [x] Rewrite response queue from `Channel` to `OutputStream` * [x] Rename mutable state-map keywords so they end with `_` * [x] Add IPv6 listener support * [x] Add Unix domain socket listener support * [ ] Double-check all volatile uses for correctness * [ ] Decide whether `AtomicBoolean` values should become atoms ## Runtime model # Runtime model Busker is a Clojure server built on libh2o, a native HTTP engine written in C. Those two sides have opposite ideas about time. The native side is responsible for the sockets and must react promptly to network events, so it cannot block on application work. The Clojure side runs ordinary Ring handlers, and waiting is most of what they do: on request bodies, on databases, on other services. If a slow handler could stall a network thread, one unlucky request would delay every client that thread is serving. Busker calls each dedicated network thread a _worker_. Each worker has one _event loop_ and watches its sockets for activity. The `:n-workers` setting fixes the number of network workers, and therefore the number of network threads. This setting does not limit application threads because Busker starts one virtual thread for each request. The `:max-connections` setting limits the number of open connections shared by all workers. When that limit is reached, the workers stop accepting new connections until an existing connection closes. When one of a worker’s sockets is ready, its event loop might accept a connection, negotiate TLS and HTTP, read part of a request, or write part of a response. The worker then moves to the next ready socket instead of waiting for one client to finish. Those threads hand requests to application code and return immediately to network work. Handlers and middleware run on virtual threads, which the JVM can park while they wait without holding anything the network needs. ![One Busker request crossing from a network event loop to a virtual thread](runtime-request-flow.svg) ## From startup to a Ring handler When [`ol.busker/start!`](api/ol-busker.adoc#start-BANG-) returns, Busker has created its first _runtime generation_, one running copy of the server built from a configuration snapshot. The snapshot is your config map with Busker’s defaults applied. For as long as that generation runs, Busker keeps the same listeners, network workers, request executor, handlers, and TLS setup. Startup validates the configuration, prepares TLS and certificate services, opens the sockets, and starts the network workers. If validation, listener binding, TLS setup, or another startup stage fails, `start!` cleans up the partial generation and throws instead of returning a server handle. Each event loop runs on a dedicated platform thread, an ordinary OS-backed JVM thread, and passes network events to libh2o. When a client connects, one of the generation’s event loops notices it. The [`:n-workers` setting](configuration.adoc) controls how many event loops a generation has and defaults to one. The event loops accept connections, negotiate TLS and HTTP versions, read available request data, and send response data when a socket is ready. They also enforce connection limits and track how many bytes each HTTP stream or connection may have in flight. Only these network threads work with sockets. They do not run Ring handlers or middleware. Once the request headers are ready, the event loop creates a Ring request and submits the handler to the request executor. That executor starts a new _virtual thread_, a lightweight JVM thread that the JVM can park without tying up a platform thread, for each request. The Ring handler and its synchronous middleware run there, and Busker writes the returned response from the same request task. Most web handlers spend much of their time waiting for request data, databases, or other services. The JVM can park a virtual thread during ordinary blocking Java I/O, leaving the event loops free to serve other connections. This is why a synchronous Ring handler can read an upload or wait for a database without moving to Ring’s callback API. CPU intensive work does not park. A handler that computes for a long time occupies one of the platform threads that runs virtual-thread work for the whole computation. A long native call can keep that platform thread occupied too. Move this kind of sustained CPU work to an executor sized for that work, and avoid blocking native calls in handlers. See [Ring compatibility](ring-compatibility.adoc) for the supported handler and middleware model. ## When the client cannot keep up Now suppose a handler produces a large response for a client on a slow connection. The handler can produce bytes much faster than the network can send them. Keeping every unsent byte would let one client consume memory until the process failed. _Backpressure_ means that a producer waits when the consumer has no room for more data. Busker applies backpressure with a byte-bounded queue for each response. The [`:output-buffer-size` setting](configuration.adoc) controls the response buffer size and defaults to 32 KiB. Busker also holds the response data currently in flight through the HTTP engine, but it does not keep growing the per-response queue while the client is stalled. The event loop removes a response chunk from the queue when it can hand that chunk to the HTTP engine. It waits for the engine to report progress on the current send before handing over more. Once the queue fills, the next application write or flush waits until the network side releases enough space. Busker waits rather than dropping response chunks. For an ordinary Ring body such as an `InputStream` or lazy sequence, the handler’s job ends when it returns the response map. The request task is not finished, though. Busker reads a chunk from the body, puts it into the response’s bounded queue, and repeats until the body is exhausted. When the queue is full because the client is reading slowly, that put waits like an ordinary blocking write. Underneath, the JVM parks the virtual thread and gives its platform thread other work while the event loop keeps serving other connections. The request task stays alive until it has read and queued the complete body. Long-lived responses use Busker’s response emitter so application code can send chunks after the handler returns. Backpressure applies to the emitter too, so an `emit!` or `flush` call waits on the calling thread when the response queue is full. On a virtual thread the JVM parks it, and the wait is cheap. On a platform thread the application thread remains blocked for as long as the client is slow. Run an emitter from a thread you can afford to block, ideally a virtual thread started for that stream. If application code closes an emitter, a final response completes, or libh2o reports request termination, Busker dispatches registered close callbacks on virtual threads. The callbacks run after Busker releases blocked response writers, and one failing callback does not suppress later callbacks. See the [`ResponseEmitter` API](api/ol-busker-protocols.adoc#ResponseEmitter) for callback registration and stream closure details. Uploads are bounded in the other direction. Busker asks the network side for another request-body chunk as the handler consumes the current chunk instead of copying the entire upload into JVM memory first. ### When an idle HTTP/1.1 client disconnects Busker can run a close callback only after libh2o reports that the response has ended. This matters for an HTTP/1.1 stream that stays open but has no data to send. After libh2o reads an HTTP/1.1 request, it stops reading that connection while the response is active. HTTP/1.1 lets a client send more requests on the same connection before earlier responses finish. If libh2o kept reading, one eager client could fill server memory with future requests while a long response stayed open. libh2o declines that invitation and leaves the unread data in the network instead. The tradeoff is that libh2o may not notice when an idle client disconnects. The operating system may have received the close, but Busker does not learn about it until libh2o checks the connection again. A normal TCP close does not settle the matter, because apparently closing a connection needed nuance. A client may stop sending while it continues to receive the response. Treating every normal close as an abandoned request would disconnect clients that still expect an answer. If the application sends no more data, the close callback may wait until a later write or an explicit emitter close. Graceful shutdown also waits for the active stream rather than closing it. An application that needs a prompt result can choose to send a heartbeat or close the stream after a timeout and force the client to reconnect. HTTP/2 and HTTP/3 do not have this limitation. The close callback runs after an idle client disconnect without waiting for another application write. Those protocols keep listening for messages about open streams while a response is idle. ## Reloading while requests are active Suppose the configuration changes while that slow response is still open. [`ol.busker/reload!`](api/ol-busker.adoc#reload-BANG-) does not tear down the running generation and then try to replace it. It builds a candidate generation beside the active one while the active generation keeps serving traffic. An unchanged configuration snapshot returns `:unchanged` unless the caller requests a forced reload. If the candidate cannot start, `reload!` cleans it up, throws an exception, and leaves the active generation serving traffic. The old generation has not begun shutting down at this point. A configuration that validates but cannot bind a listener or initialize TLS therefore does not take the working server with it. A successful reload starts the candidate and makes it active before asking the old generation to stop accepting work. New requests use the new handlers and configuration. Requests and response streams already running on the old generation keep using its handler and configuration until they finish. A request never jumps to the new generation halfway through. The old generation then drains in the background. `reload!` returns after the old generation has stopped accepting work, not after every old request has finished. Several generations may be draining after several quick reloads, each with the requests that began while it was active. ## Draining and stopping _Draining_ means that a generation accepts no new work while its current requests and response streams finish and its open connections close. Reload uses draining to retire one generation without delaying the new one. Shutdown uses the same path but waits for it to complete. [`ol.busker/stop!`](api/ol-busker.adoc#stop-BANG-) starts draining the active generation and waits for every active or already draining generation. Busker waits for their response streams, close callbacks, request executors, network workers, listeners, and runtime services before `stop!` returns. A slow request or open stream can therefore delay application shutdown. Each generation gives its request executor up to 60 seconds to finish before asking remaining tasks to stop immediately. Application code should treat interruption as cancellation and release its own resources promptly. Call `stop!` from an application lifecycle thread or shutdown hook rather than from a request handler, because a handler belongs to the work that `stop!` waits for. Calling `stop!` again after the server has stopped is harmless. ## Related documentation * [Your first Busker server](tutorial-first-server.adoc) shows the plain Ring adapter lifecycle from setup through shutdown * [Serve HTTPS and HTTP/3](howto-https-http3.adoc) applies the same lifecycle to managed certificates and HTTP/3 * [Configuration](configuration.adoc) lists runtime, buffering, connection, and protocol settings * [Ring compatibility](ring-compatibility.adoc) defines the request, response, middleware, and streaming forms * [API reference](api.adoc) documents the public lifecycle and response emitter calls ## Security # Security Please report vulnerabilities through [GitHub Security Advisories](https://github.com/outskirtslabs/busker/security/advisories). For general policy and support expectations, see [Outskirts Labs Security Policy](https://docs.outskirtslabs.com/security-policy). ## Your first Busker server # Your first Busker server This tutorial takes you from an empty directory to a running HTTP server that answers a `curl` request and shuts down cleanly. It uses plain HTTP with no certificates. You only need JDK 25 or later and the Clojure CLI. The finished project is in [`examples/first-server`](https://github.com/outskirtslabs/busker/tree/main/examples/first-server). ## Requirements * JDK 25 or later * The [Clojure CLI](https://clojure.org/guides/install_clojure) ## Set up the project Create a directory and a `deps.edn` in it. The file adds Busker and one native package that matches your operating system and CPU. Busker ships its native code as a separate jar for each supported platform. ```clojure {:paths ["."] :deps {org.clojure/clojure {:mvn/version "1.12.5"} ;; Choose the package for your platform. com.outskirtslabs.busker/linux-x86-64 {:mvn/version "0.0.4"} com.outskirtslabs/busker {:git/url "https://github.com/outskirtslabs/busker" :git/sha "07fb6b7962a8d534bbb11006c78a4c5a99160d97"}} :aliases {:repl {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}} ``` The example uses the Linux x86-64 package. Replace that line with the matching entry below if you use another platform: ```clojure com.outskirtslabs.busker/linux-x86-64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/linux-aarch64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/macos-x86-64 {:mvn/version "0.0.4"} com.outskirtslabs.busker/macos-aarch64 {:mvn/version "0.0.4"} ``` The `--enable-native-access=ALL-UNNAMED` JVM option lets Busker call its native code without a startup warning. Run this command now, and run it again every time you update the Busker commit hash: ```bash clj -X:deps prep ``` ## Write the handler and config A Busker handler is an ordinary synchronous Ring handler: it takes a request map and returns a response map. See [Ring compatibility](ring-compatibility.adoc) for the request keys Busker supplies and the response forms it accepts. Create `main.clj`. ```clojure (ns main (:require [ol.busker :as busker])) (defn handler [req] {:status 200 :headers {"content-type" "text/plain; charset=utf-8"} :body (str "Hello from Busker over " (:protocol req) ".\n" "Busker can compress this response automatically when the client asks for it.\n")}) (def config {:entrypoints {:http {:bind "127.0.0.1:8080" :tls false}} :dispatch [{:handler handler}]}) (def server (atom nil)) (defn start! [] (reset! server (busker/start! config)) nil) (defn stop! [] (busker/stop! @server) (reset! server nil)) ``` The config is a plain map. `:entrypoints` names one listener bound to `127.0.0.1:8080` with TLS turned off, so the server speaks plain HTTP. `:dispatch` is the request pipeline; here it holds one dispatcher whose `:handler` is your Ring handler. See [Configuration](configuration.adoc) for the full config shape. `start!` saves Busker’s opaque server handle in the `server` atom. `stop!` uses that handle to stop the server and then clears the atom. ## Run it Start a Clojure REPL from the project directory: ```bash clojure -M:repl ``` Load the example and start the server: ```clojure (require '[main :as app] '[ol.busker :as busker]) (app/start!) ``` The server now listens on `http://127.0.0.1:8080`. Keep this REPL open. ## Verify the response In another terminal, send a request with `curl`: ```bash curl -i http://127.0.0.1:8080/ ``` You get a `200` response whose body names the protocol curl negotiated: ```text HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 content-length: 110 Hello from Busker over HTTP/1.1. Busker can compress this response automatically when the client asks for it. ``` ## Try built-in compression Busker compresses eligible responses when the client sends an `accept-encoding` header. Compression is enabled by default, so the server config needs no changes. Ask for gzip and tell curl to decompress the body: ```bash curl --compressed -H 'accept-encoding: gzip' -i http://127.0.0.1:8080/ ``` The response headers show that Busker selected gzip. Curl prints the decompressed body because of `--compressed`. ```text HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 content-encoding: gzip vary: accept-encoding Hello from Busker over HTTP/1.1. Busker can compress this response automatically when the client asks for it. ``` Busker can also negotiate Brotli and Zstandard when a client advertises `br` or `zstd`. ## Inspect the server state `app/server` is an atom containing the handle returned by [`ol.busker/start!`](api/ol-busker.adoc#start-BANG-). Evaluate this in the same REPL: ```clojure (select-keys (busker/state @app/server) [:phase]) ;; => {:phase :running} ``` [`ol.busker/state`](api/ol-busker.adoc#state) returns the current phase and the normalized config snapshot. Evaluate `(busker/state @app/server)` without `select-keys` to inspect the full map. ## Stop the server Stop the server from the same REPL: ```clojure (app/stop!) @app/server ;; => nil ``` `app/stop!` calls [`ol.busker/stop!`](api/ol-busker.adoc#stop-BANG-), waits for the server to stop, and clears the atom. ## Where to go next * [Configuration](configuration.adoc) covers TLS, HTTP/3, multiple entrypoints, and every other config key * [Ring compatibility](ring-compatibility.adoc) covers requests, responses, bodies, middleware, and streaming