lalinsky/dusty

HTTP client/server library for Zig

C

132

322 commits

updated Sep 23, 2026

See the code

README

Dusty is a HTTP client/server library built on top of Zig's standard library I/O interface (std.Io) and llhttp (HTTP parser from NodeJS).

The library was originally written for zio, and later ported to std.Io. It's still recommended to use it with zio's implementation of the std.Io interface, especially if you need to communicate with other services over the network in your HTTP request handlers, or if you are using WebSocket. However, it's usable with any implementation, like std.Io.Threaded, or even the simulated implementation from Marionette.

The server API is inspired by Karl Seguin's http.zig, and tries to be as compatible as possible.

Features

  • Router with support for parameters and wildcards
  • Supports HTTP/1.0 and HTTP/1.1
  • Supports chunked transfer encoding in both request/response bodies
  • Transparent gzip/deflate decoding of request and response bodies
  • Server-Sent Events (SSE) for streaming responses
  • WebSocket support (RFC 6455)
  • HTTP/HTTPS client with connection pooling
  • Unix domain socket support for client connections
  • Optional TLS support in both client and server, including mTLS for authentication (via tls.zig)

Installation

zig fetch --save "git+https://github.com/lalinsky/dusty#v0.3.1"

Then in your build.zig, add the module as a dependency:

const dusty = b.dependency("dusty", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("dusty", dusty.module("dusty"));

Usage

Server Example

const std = @import("std");
const http = @import("dusty");

fn handleUser(req: *http.Request, res: *http.Response) !void {
    const user_id = req.params.get("id") orelse "guest";
    try req.io.sleep(.fromMilliseconds(10), .real);
    try res.json(.{ .id = user_id, .name = "John Doe" }, .{});
}

pub fn main(init: std.process.Init) !void {
    var server = http.Server(void).init(init.gpa, init.io, .{}, {});
    defer server.deinit();

    server.router.get("/user/:id", handleUser);

    const addr: http.Address = .{ .ip = try std.Io.net.IpAddress.parse("127.0.0.1", 8080) };
    try server.listen(addr);
}

Client Example

const std = @import("std");
const http = @import("dusty");

pub fn main(init: std.process.Init) !void {
    var client = http.Client.init(init.gpa, init.io, .{});
    defer client.deinit();

    var response = try client.fetch("http://httpbin.org/get", .{});
    defer response.deinit();

    std.debug.print("Status: {any}\n", .{response.status()});

    if (try response.body()) |body| {
        std.debug.print("Body: {s}\n", .{body});
    }
}

HTTPS and Client Certificates

By default the client verifies servers against the system trust store. ClientConfig.tls overrides that, and adds a client certificate for servers that require mutual TLS:

var client = http.Client.init(init.gpa, init.io, .{
    .tls = .{
        // .system (default), .{ .file = ... }, .{ .dir = ... }, or .none
        .ca = .{ .file = .{ .path = "ca.pem" } },
        // Presented when the server asks the client to authenticate itself.
        .client_certificate = .{ .cert_path = "client.pem", .key_path = "client.key" },
    },
});

The key must be an unencrypted PKCS#8 (BEGIN PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY) PEM file.

These settings apply to every connection a client makes; connections are pooled and reused across requests, so they cannot be varied per request. Use a separate Client per identity.

The server side is symmetric — client_auth makes it ask connecting clients for a certificate:

var server = http.Server(void).init(gpa, io, .{
    .tls = .{
        .cert_path = "server.pem",
        .key_path = "server.key",
        .client_auth = .{
            .ca = .{ .file = .{ .path = "client-ca.pem" } },
            // .require (default) rejects a client that sends no certificate;
            // .request asks for one but accepts an empty reply.
            .mode = .require,
        },
    },
}, {});

Unix Socket Client Example

For communicating with services like Docker Engine:

var response = try client.fetch("http://localhost/v1.41/info", .{
    .unix_socket_path = "/var/run/docker.sock",
});
defer response.deinit();

Timeouts

Servers use finite timeouts by default so stalled or idle clients eventually release their connection slots:

  • timeout.request defaults to 30 seconds. It covers an entire request, including handler work and writing the response; a TLS handshake gets its own deadline of the same length.
  • timeout.keepalive defaults to 60 seconds between requests on a persistent connection.
  • timeout.shutdown defaults to 30 seconds for a graceful shutdown drain.

Set a configured timeout to null to disable it. A handler can also replace its current request deadline with Request.setTimeout. It accepts std.Io.Timeout, so the handler can use a relative duration, provide an exact deadline, or disable the deadline with .none:

req.setTimeout(.{
    .duration = .{ .raw = .fromSeconds(60), .clock = .awake },
});
req.setTimeout(.{ .deadline = deadline });
req.setTimeout(.none);

Long-lived handlers can use this as an inactivity timeout by re-arming it before each WebSocket message or event, without disabling the resilient server default for ordinary requests.

The client bounds each request the same way. ClientConfig.timeout defaults to 30 seconds and covers the whole of fetch: connecting, the TLS handshake, sending the request, every redirect, and the response through the end of its body, which fetch reads before returning. A request that runs past it fails with error.Timeout. FetchOptions.timeout replaces the default for one request:

var client = http.Client.init(gpa, io, .{ .timeout = .fromSeconds(5) });

// Inherits the five seconds.
var a = try client.fetch(url, .{});
// Its own limit, counted from this call.
var b = try client.fetch(url, .{
    .timeout = .{ .duration = .{ .raw = .fromSeconds(120), .clock = .awake } },
});
// An absolute deadline, such as one shared with other work.
var c = try client.fetch(url, .{ .timeout = .{ .deadline = deadline } });
// No limit at all.
var d = try client.fetch(url, .{ .timeout = .none });

A request with .stream = true leaves the body on the wire for the caller to read through ClientResponse.reader. The deadline then covers fetch through the end of the head, and the body reads are not bounded at all.

On zio, deadlines cancel the connection task directly, which needs a zio new enough to have AutoCancel.setClock. Other I/O backends use a watchdog task; with std.Io.Threaded, that means a second OS thread for each connection while either request or keepalive timeouts are enabled, and on the client side a second thread for each fetch while a timeout is set.

Selecting the I/O Backend

The examples above use init.io, the threaded I/O implementation from the stdlib. This is suitable for development or small servers.

For production use, it's recommended to use zio, which provides a coroutine-based async I/O runtime. This allows you to serve many more requests using just a few OS threads. This is especially important if you need to wait on other network services inside your request handlers. In the future, you can also use std.Io.Evented, but that implementation is not finished yet, it's missing any networking functionality, so use zio for now.

Add it as a dependency:

zig fetch --save "git+https://github.com/lalinsky/zio"

In build.zig, add the zio module:

const zio = b.dependency("zio", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("zio", zio.module("zio"));

Then initialize zio's runtime and pass it to dusty:

const std = @import("std");
const zio = @import("zio");
const http = @import("dusty");

pub fn main(init: std.process.Init) !void {
    var rt = try zio.Runtime.init(init.gpa, .{});
    defer rt.deinit();

    var server = http.Server(void).init(init.gpa, rt.io(), .{}, {});
    defer server.deinit();

    // ... continue as before ...
}

Databases:

Message brokers:

Serialization:

  • msgpack.zig - Fast MsgPack serialization library for static types
  • json.zig - Fast JSON serialization library for static types

Templating:

  • zmpl - Templating language inspired by Go Templ
  • zt - Another templating language inspired by Go Templ

Others:

  • xsync.zig - Synchronization primitives that work across multiple std.Io implementations
async
http
http-client
http-server
websocket
websocket-client
websocket-server
zig
zig-package

Contributors

lalinsky

305 commits

LmanTW

6 commits

sb2bg

3 commits

lalinsky/dusty

HTTP client/server library for Zig

C

132

322 commits

updated Sep 23, 2026

See the code

README

Dusty is a HTTP client/server library built on top of Zig's standard library I/O interface (std.Io) and llhttp (HTTP parser from NodeJS).

The library was originally written for zio, and later ported to std.Io. It's still recommended to use it with zio's implementation of the std.Io interface, especially if you need to communicate with other services over the network in your HTTP request handlers, or if you are using WebSocket. However, it's usable with any implementation, like std.Io.Threaded, or even the simulated implementation from Marionette.

The server API is inspired by Karl Seguin's http.zig, and tries to be as compatible as possible.

Features

  • Router with support for parameters and wildcards
  • Supports HTTP/1.0 and HTTP/1.1
  • Supports chunked transfer encoding in both request/response bodies
  • Transparent gzip/deflate decoding of request and response bodies
  • Server-Sent Events (SSE) for streaming responses
  • WebSocket support (RFC 6455)
  • HTTP/HTTPS client with connection pooling
  • Unix domain socket support for client connections
  • Optional TLS support in both client and server, including mTLS for authentication (via tls.zig)

Installation

zig fetch --save "git+https://github.com/lalinsky/dusty#v0.3.1"

Then in your build.zig, add the module as a dependency:

const dusty = b.dependency("dusty", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("dusty", dusty.module("dusty"));

Usage

Server Example

const std = @import("std");
const http = @import("dusty");

fn handleUser(req: *http.Request, res: *http.Response) !void {
    const user_id = req.params.get("id") orelse "guest";
    try req.io.sleep(.fromMilliseconds(10), .real);
    try res.json(.{ .id = user_id, .name = "John Doe" }, .{});
}

pub fn main(init: std.process.Init) !void {
    var server = http.Server(void).init(init.gpa, init.io, .{}, {});
    defer server.deinit();

    server.router.get("/user/:id", handleUser);

    const addr: http.Address = .{ .ip = try std.Io.net.IpAddress.parse("127.0.0.1", 8080) };
    try server.listen(addr);
}

Client Example

const std = @import("std");
const http = @import("dusty");

pub fn main(init: std.process.Init) !void {
    var client = http.Client.init(init.gpa, init.io, .{});
    defer client.deinit();

    var response = try client.fetch("http://httpbin.org/get", .{});
    defer response.deinit();

    std.debug.print("Status: {any}\n", .{response.status()});

    if (try response.body()) |body| {
        std.debug.print("Body: {s}\n", .{body});
    }
}

HTTPS and Client Certificates

By default the client verifies servers against the system trust store. ClientConfig.tls overrides that, and adds a client certificate for servers that require mutual TLS:

var client = http.Client.init(init.gpa, init.io, .{
    .tls = .{
        // .system (default), .{ .file = ... }, .{ .dir = ... }, or .none
        .ca = .{ .file = .{ .path = "ca.pem" } },
        // Presented when the server asks the client to authenticate itself.
        .client_certificate = .{ .cert_path = "client.pem", .key_path = "client.key" },
    },
});

The key must be an unencrypted PKCS#8 (BEGIN PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY) PEM file.

These settings apply to every connection a client makes; connections are pooled and reused across requests, so they cannot be varied per request. Use a separate Client per identity.

The server side is symmetric — client_auth makes it ask connecting clients for a certificate:

var server = http.Server(void).init(gpa, io, .{
    .tls = .{
        .cert_path = "server.pem",
        .key_path = "server.key",
        .client_auth = .{
            .ca = .{ .file = .{ .path = "client-ca.pem" } },
            // .require (default) rejects a client that sends no certificate;
            // .request asks for one but accepts an empty reply.
            .mode = .require,
        },
    },
}, {});

Unix Socket Client Example

For communicating with services like Docker Engine:

var response = try client.fetch("http://localhost/v1.41/info", .{
    .unix_socket_path = "/var/run/docker.sock",
});
defer response.deinit();

Timeouts

Servers use finite timeouts by default so stalled or idle clients eventually release their connection slots:

  • timeout.request defaults to 30 seconds. It covers an entire request, including handler work and writing the response; a TLS handshake gets its own deadline of the same length.
  • timeout.keepalive defaults to 60 seconds between requests on a persistent connection.
  • timeout.shutdown defaults to 30 seconds for a graceful shutdown drain.

Set a configured timeout to null to disable it. A handler can also replace its current request deadline with Request.setTimeout. It accepts std.Io.Timeout, so the handler can use a relative duration, provide an exact deadline, or disable the deadline with .none:

req.setTimeout(.{
    .duration = .{ .raw = .fromSeconds(60), .clock = .awake },
});
req.setTimeout(.{ .deadline = deadline });
req.setTimeout(.none);

Long-lived handlers can use this as an inactivity timeout by re-arming it before each WebSocket message or event, without disabling the resilient server default for ordinary requests.

The client bounds each request the same way. ClientConfig.timeout defaults to 30 seconds and covers the whole of fetch: connecting, the TLS handshake, sending the request, every redirect, and the response through the end of its body, which fetch reads before returning. A request that runs past it fails with error.Timeout. FetchOptions.timeout replaces the default for one request:

var client = http.Client.init(gpa, io, .{ .timeout = .fromSeconds(5) });

// Inherits the five seconds.
var a = try client.fetch(url, .{});
// Its own limit, counted from this call.
var b = try client.fetch(url, .{
    .timeout = .{ .duration = .{ .raw = .fromSeconds(120), .clock = .awake } },
});
// An absolute deadline, such as one shared with other work.
var c = try client.fetch(url, .{ .timeout = .{ .deadline = deadline } });
// No limit at all.
var d = try client.fetch(url, .{ .timeout = .none });

A request with .stream = true leaves the body on the wire for the caller to read through ClientResponse.reader. The deadline then covers fetch through the end of the head, and the body reads are not bounded at all.

On zio, deadlines cancel the connection task directly, which needs a zio new enough to have AutoCancel.setClock. Other I/O backends use a watchdog task; with std.Io.Threaded, that means a second OS thread for each connection while either request or keepalive timeouts are enabled, and on the client side a second thread for each fetch while a timeout is set.

Selecting the I/O Backend

The examples above use init.io, the threaded I/O implementation from the stdlib. This is suitable for development or small servers.

For production use, it's recommended to use zio, which provides a coroutine-based async I/O runtime. This allows you to serve many more requests using just a few OS threads. This is especially important if you need to wait on other network services inside your request handlers. In the future, you can also use std.Io.Evented, but that implementation is not finished yet, it's missing any networking functionality, so use zio for now.

Add it as a dependency:

zig fetch --save "git+https://github.com/lalinsky/zio"

In build.zig, add the zio module:

const zio = b.dependency("zio", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("zio", zio.module("zio"));

Then initialize zio's runtime and pass it to dusty:

const std = @import("std");
const zio = @import("zio");
const http = @import("dusty");

pub fn main(init: std.process.Init) !void {
    var rt = try zio.Runtime.init(init.gpa, .{});
    defer rt.deinit();

    var server = http.Server(void).init(init.gpa, rt.io(), .{}, {});
    defer server.deinit();

    // ... continue as before ...
}

Databases:

Message brokers:

Serialization:

  • msgpack.zig - Fast MsgPack serialization library for static types
  • json.zig - Fast JSON serialization library for static types

Templating:

  • zmpl - Templating language inspired by Go Templ
  • zt - Another templating language inspired by Go Templ

Others:

  • xsync.zig - Synchronization primitives that work across multiple std.Io implementations
async
http
http-client
http-server
websocket
websocket-client
websocket-server
zig
zig-package

Contributors

lalinsky

305 commits

LmanTW

6 commits

sb2bg

3 commits

Languages

C

66.8%

Zig

33.1%