Skip to content

Changelog

All notable changes to this project will be documented in this file.

Unreleased

This release changes how handlers are written: they take only the parameters they need, including typed path, query and header values. Middleware gets per-request context and hooks around sending the response. HTTP/1.1 handling is stricter and follows RFC 9110 and RFC 9112 more closely, after an audit with HTTP Garden. Dusty also has a documentation site now, at https://lalinsky.github.io/dusty/.

Highlights

  • Handlers take only the parameters they need, in any order: *App, *Request, *Response, *App.RequestContext, and typed values from the request. fn index(res: *http.Response) !void is a valid handler. notFound and uncaughtError take their parameters the same way.
  • http.Param("id", u64) reads a path parameter as an integer, enum, bool, string, or your own type with parseParam. The name is checked against the route path at compile time. A value that doesn't parse doesn't match the route, and routing moves on to the next one.
  • http.Query and http.Header read a query parameter or header the same way. A missing or malformed value is answered with a 400. Use ?T for an optional one.
  • App.RequestContext declares per-request data that middleware can fill in for handlers, such as the logged-in user. Middleware uses executor.ctx. Handlers take *App.RequestContext or call req.ctx(App).
  • Middleware can declare onHeaders, which runs just before the response headers are sent, and onComplete, which runs after the response is sent or fails.
  • Groups can be nested with group.group(...), and a group can have no prefix. The outer group's middleware runs before the inner group's.

Routing

  • Routes are matched against the decoded path, one segment at a time. /a%2Fb is the single segment a/b, and route paths are decoded the same way. req.path holds the decoded path and req.query_string the raw query.
  • A malformed percent escape in the path is answered with a 400. In the query string and form bodies, a % that doesn't start an escape is now kept as a literal %.
  • Routes can name a parameter at the same position differently, such as /users/:id and /users/:name/posts. The second route used to get its value under the first route's name.
  • A route on the same path as router.static takes precedence over the directory's index, regardless of registration order.
  • StaticOptions.cache_control sets Cache-Control on files served with router.static.

HTTP conformance

  • The server now sends an error response (400, 414, 431, 501 or 505) before closing the connection on a request it can't accept. Requests for a later HTTP/1.x version are served as HTTP/1.1.
  • HTTP/1.1 requests must have one valid Host header; a missing, duplicate or malformed one gets a 400. An absolute-form request target is routed by its path, and its authority is used as the host. Malformed request targets get a 400.
  • Client errors, such as a missing Query value, a body over max_body_size or broken chunked framing, are now answered with a 400, 413 or 415 even when the app declares uncaughtError. This happens whatever error the handler returns. A body over the limit always closes the connection.
  • Chunk extensions, trailers and chunk-size lines are limited to 16 KiB, in both the server and the client.
  • Trailing whitespace is trimmed from header values.
  • A connection is closed after an HTTP/1.0 message with Transfer-Encoding, in both the server and the client.
  • Every response carries a Date header, unless the handler set one.
  • llhttp is updated to 9.4.3.

HTTP client

  • A CONNECT request now uses host:port as its request target.
  • A response whose Transfer-Encoding is anything other than chunked now fails with error.UnsupportedTransferEncoding.

Other changes

  • The Session middleware now also sets the session cookie on streamed responses.
  • Added ContentType.cbor (application/cbor).
  • ServerConfig.request.max_header_count now defaults to 64 instead of 32.
  • zig build docs builds the API reference.

Upgrade notes

  • Server.ctx is renamed to Server.app. In middleware, executor.ctx is now the request context; the app is executor.app.
  • Declaring App.dispatch is now a compile error. Use a middleware instead.
  • The Session middleware keeps the session in the request context, so the app must declare pub const RequestContext = struct { session: http.SessionData = .{} };. req.session is removed; use ctx.session.
  • The path and handler passed to get, post and the other route methods must be known at compile time.
  • Router.Group is now a function of the prefix, Router.Group(prefix). Code that names the type should use @TypeOf(router.group(...)) instead.
  • Registering two routes for the same method and path now panics, instead of the second one replacing the first.
  • Values in req.params are now percent-decoded.
  • ServerConfig.request.max_param_count is removed. A request gets only the parameters its route declares, so there is nothing to limit.
  • An HTTP/1.1 request without Host is answered with a 400.
  • uncaughtError no longer receives the 400, 413 and 415 client errors. To handle them yourself, for example as JSON, set ServerConfig.request.answer_client_errors = false; req.clientErrorStatus() returns the status the server would have sent.
  • res.stream(), res.startEventStream() and res.upgradeWebSocket() can return error.HeadersHookFailed when a middleware's onHeaders fails. Nothing has been sent at that point. If the handler returns it, uncaughtError receives the hook's original error.
  • A Request built by hand, for example in tests, needs req.splitTarget() before it is passed to Router.findHandler.

0.5.0 - 2026-10-25

This release adds file and asset serving, more ways to produce a response body, and a more reliable HTTP client, while making TLS, compression and memory use cheaper per connection.

Highlights

  • Added router.static(prefix, dir, opts) and Group.static for serving a directory, router.embedded(path, data, opts) and Group.embedded for serving a file compiled into the binary, and the addAssets build helper, which embeds a directory of assets at build time and gives templates cache-busting URLs such as /assets/app.css?v=6ec8ea7d.
  • Added Response.render(content_type, template, args) for templating libraries such as zt, Response.print(content_type, fmt, args) for formatting a body directly, and Response.encode(content_type, encodeFn, value), which writes the body with a serializer's write-to-writer function.
  • WebSocket gains autoflush and flush(), so frames can be batched into fewer writes, and receiveMany and tryReceive for taking every message that has already arrived. The default behavior is unchanged.
  • Added query() to Router and Group for QUERY requests, and any() now matches every method, following Go's ServeMux: a request is matched against routes of its own method first, then HEAD against GET routes, then any() routes.
  • TLS certificates and CA bundles can come from bytes in memory as well as files, for both listeners and the client's mutual-TLS certificate. Listeners offer protocols through ALPN with ServerConfig.Tls.alpn_protocols, and Connection.negotiatedProtocol() reports the one agreed.
  • The use_bundled_tls build option lets an application keep TLS enabled while injecting its own tls module in place of Dusty's pinned tls.zig. Connections call tls.Connection.deinit on close when the module declares one, so a module backed by a C library does not leak a connection object per connection.
  • Added examples/todo, a TodoMVC-style app built with zt, pg.zig and htmx.

Performance and memory

  • TLS responses are encrypted in place: the body is written straight into the record that gets encrypted, instead of being copied through the request write buffer first.
  • Responses with compress set are gzipped at zlib level 3 instead of zlib's default, 6. On JSON bodies of a few KB this sends about 43% more responses per second, for bodies about 12% larger.
  • The connection's write buffer moved into the pooled request buffers, so an idle connection no longer holds one, and @sizeOf(Connection) drops from about 9K to under 1K. The new ServerConfig.response.write_buffer_size sets it and defaults to 16K.
  • Listener.kernel_backlog now defaults to the system's limit, net.core.somaxconn on Linux, instead of 1024. A full accept queue makes Linux drop new connections, which then wait a second or more to retry.

HTTP client

  • A response whose body was not read to the end no longer costs the connection: up to 256 KiB of it is discarded in the background, within 50 ms, and the connection goes back to the pool, with the same bounds as Go's net/http.
  • An idempotent request (GET, HEAD, OPTIONS, TRACE, QUERY, or one with an Idempotency-Key header) that fails on a pooled connection before any response arrives is sent once more on a new connection, within the fetch's deadline. This covers servers that close idle connections without a Keep-Alive: timeout= hint.
  • When sending a request fails because the server has already answered and closed, for example with a 413 for a body it rejects, the client returns that response instead of ConnectionResetByPeer.
  • 301 and 302 redirects keep a QUERY request's method and body, as RFC 10008 requires; only 303 turns it into a GET.
  • A redirect no longer drains its body when the connection will not be reused, which could block until 2 KB arrived or the deadline passed.
  • Connections are no longer returned to the pool after a CONNECT request.

Fixes

  • The server closes the connection after a CONNECT request. A body sent with one was left on the connection and served as the next request, which behind a proxy that frames CONNECT bodies by Content-Length allowed request smuggling.
  • A handler that streamed part of its response before reading the request body no longer gets 100 Continue written into the middle of it.
  • An HTTP/1.0 request that asks to keep the connection now gets Connection: keep-alive on its response when the server keeps it. Without it an HTTP/1.0 client (ApacheBench with -k, for one) waits for the server to close, while the server waits for the next request.
  • A failed TLS handshake is now logged at info instead of err (only OutOfMemory stays at err). Any peer that reaches the port can fail one at will, so err-level logging let anyone flood the error log.

Upgrade notes

  • ServerConfig.Tls.cert_path, key_path and dir are replaced by .certificate = .{ .file = .{ .cert_path = ..., .key_path = ... } } or .certificate = .{ .bytes = .{ .cert = ..., .key = ... } }. TlsConfig.client_certificate takes the same union. An injected tls module now needs CertKeyPair.fromSlice as well as fromFilePath.
  • timeout.keepalive now defaults to 30 seconds instead of 60, the same as timeout.request.
  • Router.all_methods is removed. Registering a method route and an any() route where both match some path and the method route is not at least as specific now panics, naming both routes; for example get("/users/:id") with any("/users/me"). This includes static("/") or get("/*") together with an any() route under a prefix.
  • Listener.kernel_backlog is now ?u31; null, the default, means the system's limit.

0.4.0 - 2026-10-06

This release expands how servers listen and send responses, while improving throughput and reducing per-connection memory use.

Highlights

  • A server can now serve any number of listeners with one router and context, including plain HTTP, HTTPS with per-listener TLS settings, and Unix sockets. Handlers can distinguish them through Request.listener and Request.secure, and Server.addresses exposes every bound address once the server is ready.
  • Added opt-in gzip response compression. Set res.compress = true to negotiate gzip from Accept-Encoding for buffered bodies, streamed bodies, and event streams. Dusty manages Vary, weakens strong ETag values, and removes Accept-Ranges when the representation is compressed; small or non-shrinking buffered bodies remain uncompressed.
  • The server now processes pipelined HTTP/1.1 requests in order instead of closing a connection when the next request has already arrived.

Performance and reliability

  • Response.writer() now writes directly into arena-backed segments and sends them with vectored writes instead of repeatedly growing and copying one buffer. Handlers that encode JSON bodies of a few KB are about twice as fast.
  • Each listener runs multiple accept loops to improve throughput when many connections arrive at once. Listener.acceptors defaults to the base-2 logarithm of the CPUs available to the process, honoring cgroup CPU quotas and using at least two loops; set it explicitly to override the automatic value.
  • Request read buffers and arenas are pooled across connections and the pool is sharded to reduce contention. Connections allocate them only after receiving a request and normally retain them across keep-alive requests. Set ServerConfig.request.keep_buffers_between_requests to false to reduce memory held by idle connections at some cost in throughput.
  • Accept loops now continue through client-specific failures and back off on persistent resource or network errors instead of spinning or stopping the server. Connections with unread input are closed gracefully so clients can still receive the response.

Compatibility and build options

  • Dusty now builds with Zig 0.17 as well as 0.16. TLS uses a fork of tls.zig that supports both versions, and CI covers both Zig releases on Linux, macOS, and Windows.
  • Request and client response bodies with Content-Encoding: gzip or deflate are now decoded with zlib through zlib.zig, replacing std.compress.flate. Corrupt encoded bodies report error.CorruptInput, and decoder memory is allocated according to the stream's window instead of reserving 64K up front. Build with -Duse_zlib=false to omit zlib; encoded bodies then fail with error.UnsupportedContentEncoding, and the client does not advertise gzip or deflate.
  • The HTTP client now rejects URLs with a missing, empty, or overlong host instead of allowing an invalid host through request setup.

Upgrade notes

  • Listener configuration has moved to ServerConfig.listeners, which defaults to 127.0.0.1:8080 when omitted or empty, and servers now start with Server.run(). Server.listen() and ServerConfig.tls have been removed; move TLS, backlog, address-reuse, and accept-loop settings into each Listener.
  • Response.writeHeader is no longer public. Use Response.stream() when headers must be sent before the body.

0.3.1 - 2026-09-21

  • Added whole-request HTTP client deadlines. ClientConfig.timeout defaults to 30 seconds, and FetchOptions.timeout can replace or disable it for an individual request. Buffered response bodies remain covered through completion; explicitly streamed responses are left to the caller.
  • Added trusted reverse-proxy hop handling to the HTTP server. Configure ServerConfig.trusted_proxy_hops to resolve Request.remote_address safely from the right side of X-Forwarded-For; direct peers remain the default and malformed or incomplete chains fall back to them.
  • Re-exported BodyWriter and StreamingBodyWriter from the root module so callers can name the concrete types returned by Response.writer and Response.stream.
  • Added the Dusty httpbin container image and release-tagged publishing workflow.
  • Added timeout and buffered-response controls to the HTTP client example.

0.3.0 - 2026-09-20

This is a substantial release focused on making Dusty runtime-independent, safer under load, and more predictable at HTTP protocol boundaries.

Highlights

  • Dusty now targets Zig's std.Io interface directly and no longer has a hard dependency on zio. It works with std.Io.Threaded, zio, and other std.Io implementations; zio remains the recommended backend for highly concurrent servers and gets native deadline cancellation when explicitly injected.
  • TLS is now configurable on both sides of a connection. The server can serve HTTPS, and the client and server both support mutual TLS, system or custom CA stores, and certificate verification controls. TLS can be compiled out with -Duse_tls=false.
  • Server operation is bounded by default: at most 10,000 open connections, a 30-second request timeout, a 60-second keep-alive timeout, and a 30-second graceful-shutdown budget. Handlers can replace or disable their current deadline with Request.setTimeout.
  • Request and response bodies can be processed as streams. Transparent gzip/deflate decoding now applies while streaming client responses and, by default, incoming server requests. Response streaming has explicit buffered and streaming writers with reliable framing and error reporting.

HTTP client

  • Added a configurable default User-Agent and first-class TLS configuration, including custom trust roots, client certificates, and an explicit insecure mode for testing.
  • Made the connection pool safe to share across concurrent tasks, and fixed stale, unread, failed, or redirecting connections being returned to the pool.
  • Redirect handling now follows only redirect statuses, applies the expected method changes, strips credentials when crossing origins or downgrading to HTTP, sets Referer, and reuses connections when the previous response can be drained safely.
  • Improved interoperability for IPv6 literal URLs, interim 1xx responses, HEAD responses, bodyless POST/PUT/PATCH requests, and responses delimited by connection close.
  • Client response headers are bounded, duplicate header fields are preserved, and truncated or malformed bodies now return specific errors instead of generic I/O failures.

HTTP server and application API

  • Added server-side TLS and optional/required client-certificate authentication, with TLS handshakes covered by the request timeout.
  • Added transparent request-body decompression, caller-buffered body readers, typed path/query parameter lookups, request peer addresses, cookie iteration, and JSON serialization for headers, parameters, and cookies.
  • Reworked response writing so buffered, fixed-length, chunked, HTTP/1.0, HEAD, and bodyless-status responses are framed consistently. Server-Sent Events now support multi-line and incrementally produced event data.
  • Header names, values, cookies, multipart fields, body lengths, and message heads receive stricter validation. Oversized request heads receive 431, malformed query strings receive 400, and handler failures produce error responses without leaking partial bodies.
  • Graceful shutdown now waits only for in-flight requests rather than idle keep-alive connections, and connection/request limits are enforced without busy-waiting.

WebSocket and reliability

  • WebSocket sends are serialized so concurrent tasks cannot interleave frames. Incoming frames now enforce masking, fragmentation, close-code, UTF-8, opcode, and control-frame rules and reply with the appropriate protocol close.
  • WebSocket message storage is reclaimed between receives, fixing growth over long-lived connections.
  • Error propagation across plain sockets, TLS, response streams, SSE, and WebSockets now preserves the underlying transport error. Numerous cleanup paths were tightened to prevent leaks and double releases after failed connects, TLS handshakes, redirects, spawns, and disconnects.

Upgrade notes

  • Server addresses and networking types now use std.Io; Server.listen accepts dusty.Address. Applications using zio should explicitly inject its module into Dusty to enable zio-native timeout cancellation.
  • ServerConfig.timeout.request and .keepalive now take std.Io.Duration instead of millisecond integers and are finite by default. A new .shutdown timeout and max_connections limit are also enabled by default; set them to null to remove those bounds.
  • ClientConfig.use_system_ca_bundle was replaced by ClientConfig.tls; the equivalent setting is .tls = .{ .ca = .system } (also the new default).
  • Request.reader and ClientResponse.reader now take a caller-owned buffer and can fail during setup. Response.writer, Response.stream, and event writers return explicit writer objects whose end method must be called; startEventStream now also takes a buffer.
  • Headers is now a bounded, case-insensitive, multi-value collection. Use add to preserve repeated fields and access iterator entries through .key and .value. Request path and query values now use Params, which adds typed numeric lookups.

0.2.0 - 2026-04-26

  • Support for Zig 0.16, still depends on zio, not using std.Io due to lack of timeout support.
  • Fix flushing in websocket client when using it over HTTPS.
  • Added support for 100-continue in the HTTP client.

0.1.0 - 2026-03-02

Initial release.

Features

  • HTTP/1.0 and HTTP/1.1 server with router, parameters, and wildcards.
  • HTTP/HTTPS client with connection pooling and DNS resolution.
  • Unix domain socket support for client connections.
  • Chunked transfer encoding for request/response bodies.
  • Server-Sent Events (SSE) support.
  • WebSocket support (RFC 6455) for both client and server.
  • Gzip/deflate decompression for HTTP client.
  • Middleware system with CORS and session middleware.
  • Cookie support.
  • Request/keepalive timeouts via coroutine auto-cancellation.