- Zig 92.1%
- Nix 5.9%
- Python 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Config.max_body_len was documented as a limit on a request body and read nowhere. It now is one: a request whose content-length is over it is answered 413 before the handler runs, and one that sends more without declaring it has its body read fail at the limit and is answered 413 unless the handler has begun a response. Either way the client is asked to stop sending with H3_NO_ERROR, as RFC 9114 §4.1 describes. The field becomes ?u64 and defaults to null, no limit beyond what the handler reads, because that is what the HTTP/1 and HTTP/2 servers do; a 1 MiB default would have refused over HTTP/3 alone the 4 MiB upload the other two versions take. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01P9FTtURHKznA4Aeg1d8Wnp |
||
| .forgejo/workflows | ||
| doc | ||
| interop/https-records | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| CONTRIBUTING.md | ||
| flake.lock | ||
| flake.nix | ||
| GLOSSARY.md | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-http
An HTTP library for Zig 0.17, server and client, that does not know what it is running on.
Requires Zig 0.17.0; the zig-0.16 branch and the v0.1.0 tag build with
Zig 0.16.0 (see Adding it to a project).
The server reads requests from an std.Io.Reader and writes responses to an
std.Io.Writer, plus three facts about the channel that HTTP needs and cannot
read off it. That is the whole transport interface. A TCP listener and an
in-memory one are included; a TLS session, a Unix socket, or a pair of pipes
handed over by inetd would each be about fifty lines and nothing above them
would know the difference.
HTTP/1.0, HTTP/1.1, HTTP/2 and HTTP/3 are implemented, on both sides, and the same handlers serve all four without being recompiled against anything different. HTTP/2 passes 146 of h2spec's 147 conformance cases; the one exception is documented and deliberate.
This README is the overview. How to serve is doc/server.md, and how to make requests is doc/client.md.
The API documentation is at https://jeff.jcollie.page/zig-http/. It is generated from the doc comments, which carry most of the explanation in this library — the framing rules in particular are argued out where they are implemented rather than summarised here.
Status
Early. The wire formats are exercised by 836 tests, eighteen property-based
fuzz targets and eleven NixOS guests that point real implementations at it —
curl, nginx, aioquic, Traefik, tinyproxy, h2load and the h2spec
conformance suite —
and the framing rules are the ones RFC 9112 and RFC 9113 give. But it has not
served anything in anger. Read the
What is not here section before depending on it.
Where this lives
The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-http is the web-visible one:
$ git clone https://git.jcollie.dev/jeff/zig-http.git
it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-http, and it is also on the Radicle network, where the repository's identifier is
rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS
and rad clone rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS fetches it from any node that
seeds it. Any of the three is the whole project.
Adding it to a project
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-http.git
const http = b.dependency("http", .{ .target = target }).module("http");
exe.root_module.addImport("http", http);
That is the current main, which builds with Zig 0.17.0. A project still on
Zig 0.16.0 takes the last release made for it, which is also where the
zig-0.16 branch points:
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-http.git#v0.1.0
A server
const std = @import("std");
const http = @import("http");
const http_io = @import("http_io");
fn hello(req: *http.server.Request, res: *http.server.Response) anyerror!void {
try res.setHeader("Content-Type", "text/plain; charset=utf-8");
try res.sendPrint("you asked for {s}\n", .{req.target.path});
}
pub fn main(init: std.process.Init) !void {
var threaded: std.Io.Threaded = .init(init.gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var listener = try http_io.tcp.Listener.listen(
io,
.{ .ip4 = .loopback(8080) },
.{},
);
defer listener.deinit(io);
var server: http.server.Server = .init(
io,
init.gpa,
listener.listener(),
.initStateless(hello),
.{},
);
try server.serve();
}
zig build run starts a slightly larger version of this — src/main.zig — with
four routes chosen to show one thing each: a body whose length is not known in
advance, a request body read back, a status set by hand, and a handler with
state of its own.
Serving HTTP/2 and HTTP/3, what a handler owes each version, what the drivers refuse, switching protocols and TLS are all in doc/server.md.
A client
http_io.Client takes a URL and works out the rest: it resolves the name,
dials, negotiates HTTP/1.1, HTTP/2 or HTTP/3, follows redirects, and goes
through a proxy when told to. It has the shape of std.http.Client.fetch.
var client: http_io.Client = .{ .io = io, .gpa = gpa };
defer client.deinit();
var body: std.Io.Writer.Allocating = .init(gpa);
defer body.deinit();
var result = try client.fetch(.{
.location = .{ .url = "https://example.com/" },
.response_writer = &body.writer,
});
defer result.deinit();
// result.status == .ok, result.version == .http_2,
// result.headers.get("content-type").? == "text/html"
Interim responses — a 100 Continue, a 103 Early Hints — are read past on
every version and never returned as the response; interim is a hook that is
told of each one on the way, for a caller that wants a 103's Link fields.
An upload can be sent with Expect: 100-continue, holding its content back
until the server asks for it, so that a server that will refuse it — a 401,
a 413 — does so before any of it is sent. The servers answer the expectation
on every version, when the handler first reads the body.
How the version is chosen, HTTP/3 by Alt-Svc and by HTTPS records, proxies,
redirects, timeouts, streamed request bodies, interim responses, the
100-continue expectation, and the three drivers underneath it are all in
doc/client.md.
The four layers
The boundaries between them are the point of the library.
protocol — HTTP semantics
Methods, statuses, targets, header fields and dates, with nothing in it about how any of them are written down. RFC 9110 made that separation and this follows it, because it is what makes everything above reusable across versions.
A Method is a string rather than an enumeration, because that is what a
method is: RFC 9110 defines a registry anyone may add to, and a server that
models it as a closed set cannot tell PROPFIND from nonsense — and so cannot
answer the first with 405 and the second with 501.
Target.resolvePath is the security-critical piece: it percent-decodes each
segment on its own, refuses a segment that decodes to something containing a
/ or a NUL, removes . and .. after decoding so that %2e%2e is a dot
segment, and refuses a .. with nothing left to leave rather than silently
discarding it. It must be applied exactly once; the doc comment says why at
length.
transport — where the octets come from
A transport.Connection is a reader, a writer, and Info: the peer address,
the local address, whether the channel is confidential, and what ALPN settled
on. A transport.Listener is a source of them.
transport.memory is a second real implementation, and it is in src rather
than in tests deliberately: a transport-agnostic server whose only transport
is a socket is not transport-agnostic, it is a socket server with an interface
in front of it. Having two is what keeps the seam honest — and it is why the
test suite has no ports, no scheduling and no waiting, and why the fuzzer gets
through hundreds of thousands of requests a second.
http1 and http2 — the wire formats
RFC 9112's request line, field lines, Content-Length and chunked coding;
RFC 9113's frames and streams and RFC 7541's header compression. These are the
only parts of the library that know what any of that looks like, and neither
knows about the other.
That the layer boundary above them holds is now demonstrated rather than asserted: there are two implementations behind it, and neither the handler nor the transport knows which one is running.
server — the accept loop and the handler
Request, Response and Handler, none of which mention a version of HTTP.
A handler declares how long its body is going to be — .none, .{ .exact = n } or .unknown — and the driver turns that into a Content-Length, a
chunked coding, a closed connection, or a sequence of DATA frames. A handler
never writes a Content-Length, never sees a chunk, and cannot set
Connection; the driver overrules all three, because the first two are
framing and the third is an HTTP/1.1 connection-management field that HTTP/2
makes a connection error to send at all.
Two modules, and which one you want
http is the protocol and nothing that owns a file descriptor. Every byte
reaches it through a transport.Connection — a reader, a writer, and a
handful of facts the byte stream does not say — or, for HTTP/3, a
transport.Session, which is the same idea for a connection carrying many
independent streams. Neither names a socket, and neither does anything else
in the module: the addresses a handler is told about are
z46 values, which parse, format
canonically and compare, where std.Io.net.IpAddress is what a socket hands
over. http_io converts once, at the edge, and tests/typecheck.zig asserts
the two have not become the same type again. Dates — the three forms RFC 9110
§5.6.7 has a recipient read and the one it has a sender write — are
zig-datetime's rather than a
calendar of this library's own.
http_io is the other half: the TCP listener, the QUIC-backed session, the
HTTP/3 listener that owns a UDP socket, and Client, which takes a URL and so
is the one place a URL is parsed — zig-uri
is a dependency of this module and not of http, which takes a request target
and an authority as the wire has them. A program serving HTTP wants both.
Something embedding HTTP in a runtime that already has its own I/O wants only
the first, and gets to keep it.
The division is not tidiness, it is what the test suite is built on. An
HTTP/1.1 exchange is a string literal, which is why the parsers can be fuzzed
at half a million inputs a second. HTTP/3 was the exception until
transport.Session existed: every one of its tests bound a UDP port and
completed a QUIC handshake to ask a question about a frame, so a failure in
any of them was a failure somewhere in QUIC, TLS, QPACK and HTTP/3 at once.
transport.memory_session is the same driver handed a request as a slice, and
the test that does it needs no port, no handshake and no socket.
What is not here
- No dynamic QPACK table, and no
CONNECTover HTTP/3. Both argued in doc/server.md. - No server push and no RFC 9218 prioritisation. Each is argued in doc/server.md.
- No WebSocket framing. The handshake is here, over both versions; RFC 6455 itself is zig-websocket.
- A name, not an address, is what gets verified.
std.crypto.Certificatechecks the dNSName entries and the common name and never an iPAddress, so a certificate issued forIP:10.0.0.1will not match a connection to10.0.0.1however right it looks — andopenssl req -addextwrites exactly that certificate, which curl then accepts.ClientOptions.hostis the name to check, and it is deliberately separate from the address dialled. - No content codings. A request arriving
Content-Encoding: gzipreaches the handler still compressed, which is correct — a content coding is part of the representation, not the framing. - Trailer fields are consumed and discarded rather than exposed. They arrive after the handler has usually begun its response, so there is nowhere useful to put them; what matters is that they are consumed exactly, so the next request on the connection starts where it should.
- No timeouts. A connection is bounded by its buffer sizes and its request count, not by a clock. Put something in front of it that has one.
Building and testing
The toolchain comes from the flake:
$ nix develop
$ zig build test --summary all
$ zig build # the demonstration server, into zig-out/bin
$ zig build check # compile the things no test builds
The suite has four parts, and tools/docs_server.zig is a fifth: it serves
zig build docs using this library, so reading the documentation exercises
it.
src/** holds the unit tests, next to what they test. tests/e2e.zig drives
whole requests through the HTTP/1.x driver over the memory transport and
asserts on the octets that come back — every case delivered whole, then one
octet at a time, then three, because a parser that only works when its input
arrives all at once is not a parser. tests/tcp.zig does a handful of the
same over a real loopback socket, to check that the pieces underneath are
wired together.
tests/h2.zig is the same idea for HTTP/2, with two differences forced by the
protocol. The Io is real rather than Io.failing: a driver that gives each
stream a task cannot run on one that has no tasks, since a handler reading its
own request body waits for DATA frames only the reader task can deliver. And
the client is written by hand in tests/h2/frames.zig, which imports nothing
from http — so every request in the suite is framed and HPACK-coded by code
sharing nothing with the code under test, and a mistake the server and the
library's own encoder agreed about cannot hide. tests/h2/oracle.zig holds
what must be true of anything the server writes, including the property that
matters most: every field block is contiguous and complete, because HPACK
state is shared by every stream and an interrupted block leaves the peer's
decoder permanently out of step.
Because an HTTP/2 conversation is binary, a failure there prints the whole
exchange decoded frame by frame rather than a hex diff. That is dump(), and
it was written before any of the tests were.
Fuzzing
tests/fuzz.zig holds eighteen targets, each a property rather than an example:
heads— a head the parser accepted is one whose every piece is well formed, and every field points inside the buffer it came from.responses— the same question asked of the other direction, plus the one that matters more: the framing has to be decidable. A response a client accepted without knowing where its body ends is where response splitting lives.client,h2-client,h3-client— whole responses through each of the three clients, from a server saying anything at all. They found three faults: a client that accepted a204carrying both framings and five octets and then offered the connection for the next response; an HTTP/2 stream ended by a DATA frame with no field section before it, answered with the zero its status was initialised to; and a leak of the HTTP/3 body list on a path whose arena had anerrdeferand whose list did not.paths— no input may produce a path that leaves the root, keeps a dot segment, or holds a NUL.dates— a date this understood survives being written out and read again.fields— the comma-separated list grammar:containsToken, which every framing decision in the driver is made from, must never disagree with a walk of the same list.exchanges— whole requests through the HTTP/1.1 driver, after which the output is parsed back: it must be a whole number of well-framed responses with nothing left over. That is the property request smuggling breaks.hpack— nothing the HPACK decoder returns may point into the field block it was given or into the dynamic table. The block is a buffer the driver reuses; the table is a ring that evicts, and can evict during the very section that references it. Either would be a use-after-free a handler reaches in the ordinary course of its work. The table's recorded size must also equal the sum of its entries, because the peer is keeping the same table by the same rules and a disagreement makes every later index mean something different to each end.h2-requests— a request the server accepted is one no downstream HTTP/1.1 recipient could be made to re-frame: no uppercase name, no CR, LF or NUL in a value, no hop-by-hop field. This is the h2-to-1.1 gateway smuggling class.huffman— every byte string survives coding and reading back, which is the only property that catches a table transcribed one row out of place. A decode-only check would call that a perfectly good decode of something else.frames—Header.parseis total: §4.1 requires unknown types to be discarded and the reserved bit ignored, so there is no nine-octet sequence it may refuse. Parse and write are inverse, andcheckHeaderhas an answer for every header at every maximum frame size.h2-exchanges— whole connections through the HTTP/2 driver, judged by the walker intests/h2/oracle.zig. It is strictly stronger thanexchanges, which has to carve out exceptions because HTTP/1.1 framing is not self-describing: here every octet belongs to a frame whose length is written on the front of it, so the oracle can insist that every field block is contiguous and complete, that no frame arrives for a stream already finished, and that the server never names a stream it could not have opened.qpack—hpack's question asked of RFC 9204: nothing the decoder returns may point anywhere but the field section, the scratch buffer or the static table. What it deliberately does not check is that a name is lower case, which is §4.2's rule about a message and not QPACK's about a decode.h3-frames—framesfor HTTP/3's varint framing, where the header is two to sixteen octets rather than nine. Type and length survive being written back and read again; the widths deliberately do not, because §16 of RFC 9000 lets a varint be written four ways and HTTP/3 does not pick one.h3-requests— the gateway smuggling class again, one version along: what a field section has to be before it is a request.requests— the only target that fuzzes what this library writes. One request — fuzzed target, fuzzed authority, fuzzed body — is handed to all three clients, the octets each of them emits are read back, and the messages underneath are compared: same method, same path, same authority, same content, and the same answer about how long it is. Each is also checked against the version that framed it, which is where the HTTP/3 question lives: §19.8 of RFC 9000 permits a FIN in a frame carrying no data, and a peer is within its rights to want the last octets on the frame that ends the stream. Everything else here feeds a driver what a peer said, and both faults these clients actually shipped were in the other direction.
Each is an ordinary test too, so zig build test runs the seeds beside it.
h2-exchanges is the one target that needs a real Io, for the same reason
tests/h2.zig does: a driver that gives each stream a task would refuse every
stream on one that has no tasks, and the target would fuzz the refusal. It is
also where three real defects were found — a RST_STREAM naming a stream
identifier no client can own, two RST_STREAM frames for one stream when the
reader task and a stream task both decided to reset it, and a leak of one
arena chunk per request body, because the arena was copied into the stream
and then allocated from through the original. hpack found a fourth: a
use-after-free when an insertion evicts the very entry its own name was taken
from, which is the case RFC 7541 §4.4 warns about in one sentence that is easy
to read past.
exchanges fuzzes more than the request. The handler behind it is a small
router, so chunked responses, bodiless statuses, interim responses and the
protocol switch are all reached; how many octets a read delivers at a time is
part of the input; and so are the driver's limits, because most of the code
that enforces a limit only runs when something hits it.
Zig's own fuzzer searches them, steered by coverage because every test binary is compiled with LLVM rather than the self-hosted backend, which emits no instrumentation:
$ zig build fuzz --fuzz # until interrupted
$ zig build fuzz --fuzz=1M -Dfuzz-filter=paths # a bounded run, one target
tools/fuzz.zig is a second way in, and the one CI uses: a loop written down
honestly, which mutates a corpus of each target's own seeds, hands the result
to a target, checks every input for leaks, and says what came back. It is
bounded by a count or a clock and replays a single input on demand.
$ zig build fuzz-run # a minute of each
$ zig build fuzz-run -- --seconds 300 --target exchanges
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target paths
It has earned its place rather more than four times now, and the shape of what it finds is worth naming: for a long time it never found a bug in the server, only bugs in the properties written about the server and one in the fuzzer itself. What it found instead were bugs in the clients — and the most useful of those was not a parsing bug at all.
requests found it on its first seed. client.Request carries a target and
a host as plain slices, and the HTTP/1.1 driver wrote both of them straight
into the message: the target into the request line, the authority into a
Host line. Fields had been validated since there were fields to validate,
because they can only arrive through Headers.append. These two had never
been looked at by anything. A caller that builds a path out of something it
was given — which is most callers — could therefore hand the driver a target
containing a CRLF, and §2.2 of RFC 9112 is unambiguous about what happens
next:
GET /a
X-Injected: yes
Junk: HTTP/1.1
Host: example.com
send reported success. That is request splitting performed by the client
rather than against it, and the server on the other end has no way to tell the
injected field from one the caller meant. The check now lives in
client.Request.validate, which asks Target.parse — the library's own rule
for what a target may contain — and refuses an authority carrying SP, HTAB,
CR, LF or NUL. All three drivers call it, before a stream is opened or an
identifier spent, and tests/h3.zig asserts that they do: HTTP/2 and HTTP/3
are not framed by lines and could carry those octets safely, but §8.2.1 of RFC
9113 forbids them anyway, because a gateway converting the message back to
HTTP/1.1 would do the splitting one hop further on where nothing here can see
it.
The reason no other target could have found it is the reason the target exists. Seventeen of the eighteen hand a driver what a peer said and ask what it made of it. This one is the only one that reads what the driver wrote, and writing is where a client's faults live.
Twice it has found that a property was wrong rather than the code under it.
resolvePath decodes, so its result is not a request target and cannot be fed
back through the parser: /%2541 resolves to /%41, which resolves again to
/A. That is not a defect to be fixed — it is what percent-decoding is — but
it is exactly the double-decoding traversal bug that keeps getting filed
against other people's servers, and it is now written down at the top of the
function.
The other was a layering mistake in a property I had just written:
hpack asserted that no decoded value carries a CR. But HPACK is a
compression format — RFC 7541 carries whatever octets it is given, and it is
RFC 9113 §8.2.1 that forbids the CR, which makes the request malformed
rather than the connection broken. Asserting it at the decoder would have
conflated a stream error with a connection error and torn down every other
request multiplexed alongside it. The property moved to h2-requests, which
is the layer that enforces it.
And once it found that the fuzzer had been lying. Smith.value reads
eight bytes as a little-endian u64 and, when that number is outside the
range the type allows, returns the range's minimum rather than reducing it.
The generator was writing two length-prefixed chunks and then noise, so a
target that read one slice and then a u2 found the second chunk's length
header where the number should have been — out of range, every time, for
every seed. The knob answered zero forever and the branch behind it had never
run, while the tool reported millions of iterations a minute. Nothing about
that is visible from the outside, which is the whole problem: the fix was
checked by making the unreached value an error and confirming the fuzzer now
finds it in three thousand inputs and did not before.
The virtual machine tests
nix flake check boots NixOS guests and points real clients at a real
server — and, for the five that test this library's own clients, real servers
at it. There are eleven, and they ask different questions.
tests/nixos/curl.nix asks whether another implementation agrees.
Everything in the Zig test suite is this library reading what this library
wrote, and a framing mistake that is self-consistent would pass all of it;
curl is strict, has been shown every broken server on the internet, and
reports a framing disagreement as an error rather than quietly recovering. It
now runs every check twice, once over HTTP/1.1 and once with
--http2-prior-knowledge.
tests/nixos/h2spec.nix asks whether the HTTP/2 driver conforms. 147
cases from the RFC 9113 and RFC 7541 conformance suite, run --strict, with
the JUnit report checked against an allowlist that has exactly one entry and
is verified in both directions — a case that starts passing has to leave the
list, or the next real regression hides behind an entry that no longer
describes anything.
That one entry is worth stating, because it is a design decision rather than a
defect. h2spec sends INVALID CONNECTION PREFACE and expects a GOAWAY. This
server shares one cleartext port between HTTP/1.1 and HTTP/2 and tells them
apart by the first four octets — which is exactly what RFC 9113 §3.4 designed
the PRI method to allow. Those four octets are not PRI , so the bytes go
to the HTTP/1.1 driver and get an HTTP/1.1 400, which is the right answer to
that input on a shared port. h2spec is assuming the port is HTTP/2 only.
A handful of h2spec's other cases race this server rather than test it: they send a request and then, without waiting for the response, send the frame whose answer they are checking, expecting that answer to be the next frame on the connection. Each stream is served by a task of its own, so the response can legitimately arrive in between, and h2spec reports the first frame that was not what it wanted. Being timing, it moves — three local runs in a row named three different cases. A conformance failure is deterministic, so anything unexpected is judged by a second whole pass and only a case that fails in both counts; the flakes are printed rather than swallowed, because a case that starts flaking often is a race worth chasing even though it is not a conformance bug.
tests/nixos/h3.nix asks whether curl agrees about HTTP/3, which is the
same question as curl.nix over a transport where nothing about the framing
is shared with the byte-stream drivers. It needs a machine rather than a
process because HTTP/3 needs a certificate and a certificate needs a name that
resolves, which cannot be arranged against 127.0.0.1.
tests/nixos/aioquic.nix asks whether a second HTTP/3 implementation
agrees, and it is the one of these that most obviously earned itself. It is
also the only one that asks for several large answers at once, which is what
a browser does on every page and what curl never does — it reuses a
connection but waits for each answer before asking again. That difference hid
a flow control bug in which two concurrent downloads stalled for ever while
one worked. curl's
stack is ngtcp2, nghttp3 and OpenSSL; aioquic is Python, and its field
compression is Litespeed's lsqpack — a third reading of RFC 9204, in a third
language. It also has an API rather than a command line, so it can be told to
send what no ordinary client would.
That is what found the last conformance bug here. Asked for a field name with
a capital letter in it — which curl lower-cases on the way out, so no
assertion built on curl can reach the rule at all — this server answered 400 Bad Request, where §4.2 and §4.1.2 of RFC 9114 between them require a stream
error of type H3_MESSAGE_ERROR. A status code is a claim that the request was
understood well enough to be answered, which a field section the server has
refused to read plainly was not. It also asks what the server's SETTINGS said,
which curl never reports, and it checks that the octet count in a trailer
field matches the body the client counted for itself.
Beside it is aioquic_conformance.py, which is thirteen rules broken one at a
time by a client with no HTTP/3 layer at all — no SETTINGS first, a second
SETTINGS, a DATA frame where only control frames belong, a control stream the
peer ends, either QPACK stream the peer ends, a second control stream, a
request stream opening with DATA, a SETTINGS frame part way through a request,
a PUSH_PROMISE from a client, a CANCEL_PUSH for a push nobody promised, a
MAX_PUSH_ID that goes backwards, one with something after it, and a field
section over the advertised maximum, which is the one of them that is not a
connection error — §4.2.2 makes it one request's problem, answered 431,
because a reset means "nothing happened, try again" and a client would, for
ever, with the same oversized head. Each
asserts the connection error code and the reason phrase, since the phrase is
the only thing that tells the two H3_FRAME_UNEXPECTED cases apart. This is
standing in for h3spec, which would be the real conformance suite and cannot
be built: its Haskell quic dependency is marked broken in nixpkgs and its
version bounds are not satisfiable there.
tests/nixos/tls.nix asks for the one thing a cleartext port cannot give:
HTTP/2 chosen by a browser. §3.1 of RFC 9113 gives HTTP/2 over TLS the ALPN
identifier h2 and leaves no other way in — the PRI preface works only on
a cleartext port, and §3.2's Upgrade was removed by that same RFC. So every
HTTP/2 exchange with a real client anywhere runs through this path and no
other, which is why the in-process suite's HTTP/2 coverage does not stand in
for it. The certificate is generated in the guest, so nothing in the
repository is a key and curl checks the chain the way it would against
anybody.
The next three point the other way, at the clients. Everything in
tests/client.zig is this library reading bytes it was handed, and everything
in tests/tcp.zig is this library's client reading this library's server —
two halves written by one pair of hands, which can agree on a misreading and
pass every test either of them has.
tests/nixos/client.nix puts the HTTP/1.x and HTTP/2 clients in front of
nginx. With http2 on; nginx answers HTTP/1.1 and cleartext HTTP/2 on one
port by looking at the first octets, which is what this library's own server
does, so one client exercises both against a second opinion. It found two
faults the self-testing could not: a connection-level window that starts at
65535 whatever SETTINGS_INITIAL_WINDOW_SIZE says, because §6.9.2 gives that
setting to streams alone; and a frame payload taken whole when §4.2's floor of
16384 octets is larger than the default read buffer, which now streams.
tests/nixos/h3client.nix is aioquic.nix pointed at the client. That
one says what this library writes can be read by somebody else; this one says
it can read what somebody else wrote, which is a separate claim. aioquic's
QUIC, aioquic's HTTP/3 framing and Litespeed's lsqpack produce the bytes.
tests/nixos/proxy.nix is the client through somebody else's proxy.
tinyproxy stands between it and an origin whose firewall admits only the
proxy, so a fetch that works went through it — and the first assertion is that
one which tries to go direct does not. An http URL is forwarded in absolute
form, an https one is tunnelled with CONNECT and comes back HTTP/2, which
can only have been negotiated with the origin through the tunnel, and a wrong
credential is refused both ways. The first run of it took two minutes per fetch
and passed: the firewall admitted the proxy over IPv4 alone, tinyproxy tried
the origin's IPv6 address first, and waited out TCP's connect timeout before
falling back — worth knowing about any test that fences a machine off.
tests/nixos/traefik.nix asks two questions the others do not. Traefik's
HTTP/3 is quic-go — a third QUIC implementation after nginx's and aioquic's,
in a third language — and Traefik is a proxy rather than an origin. What comes
back has been through two HTTP implementations: read over HTTP/3, written
again as HTTP/1.1 to static-web-server, and the answer read and written back.
Every field has been parsed and re-serialised by something in between, which
is exactly where a length, a coding or a connection-specific field goes wrong.
tests/nixos/load.nix asks what happens with many clients at once, over
both protocols in one process. Six thousand HTTP/1.1 requests through siege,
then three thousand HTTP/2 requests through h2load at fifty streams per
connection, then bodies in both directions large enough that flow control has
to work. Three assertions only a load test can make:
- Availability is 100%, and the server's own count of what it served matches the client's count of what it asked for, exactly. Under HTTP/2 that count is the sharp end: two requests inside the handler at the same time on the same connection is something only HTTP/2 can do, and a lost atomic increment there is a race the HTTP/1.1 rounds could never reach.
- The open file descriptor count is identical before and after.
- Resident memory is compared between two identical rounds rather than against
the start, so thread-pool ramp is not mistaken for a per-request leak — and
the warm-up runs every kind of load, repeatedly, until the thread count
stops moving. That last part is not optional: a task is an operating system
thread here, and the pool grows with the peak concurrency it has been
asked for, which a fast route never reaches. Warming up on
/alone and then measuring a round containing/bulkreports a 36 MB leak that is two hundred thread stacks.
The load test earned its place immediately. It found that active_streams
never fell for a normally-completed request — the reader's half of a stream
was closed only on RST_STREAM, never on END_STREAM — so every connection
silently stopped accepting new streams after exactly max_concurrent_streams
of them. Twenty connections times a hundred streams is two thousand; a run of
three thousand requests came back with a thousand failures and not one error
logged, because from the server's point of view nothing had gone wrong.
tests/nixos/window.nix asks what --h3-window is actually worth, and it
is the only measurement here not taken over loopback — which is the point. A
send window bounds what one stream may have unacknowledged, so what it buys is
window / round-trip, and on loopback the bandwidth-delay product is a couple
of kilobytes that the default window already exceeds. Measured there, every
window above about eight kilobytes looks identical and a reader would
reasonably conclude the setting does nothing. netem gives the path ten
milliseconds each way and a hundred megabits, so the bandwidth-delay product
is about 250 KB: below that the window is the binding constraint, above it it
is not, and the curve says so.
All ten need KVM, so none runs on an always-on Forgejo runner. The workflow puts them on an ephemeral DigitalOcean tier instead.
API documentation
Generated from the doc comments, which carry most of the explanation in this library:
$ zig build docs # into zig-out/docs
$ zig build docs-serve # and read them at http://127.0.0.1:8000/
It has to be served rather than opened: the viewer is a WebAssembly program
that fetches its sources at runtime, and a browser refuses that from a
file:// page.
tools/docs_server.zig is that server, and it is built on this library. A
project that ships an HTTP server and then reaches for somebody else's to
serve its own documentation has said something about its library that no
README can take back — and it is the most useful test there is besides, being
a second real consumer of the API doing the thing most consumers will do
(resolve a path, send a file) with a browser as its client.
References cited
Belshe, M., R. Peon, and M. Thomson, eds. 2015. Hypertext Transfer Protocol Version 2 (HTTP/2). RFC 7540. RFC Editor. Obsoleted by RFC 9113; cited for the history of the connection preface.
Berners-Lee, T., R. Fielding, and L. Masinter. 2005. Uniform Resource Identifier (URI): Generic Syntax. RFC 3986. RFC Editor. https://www.rfc-editor.org/info/rfc3986.
Bishop, M., ed. 2022. HTTP/3. RFC 9114. RFC Editor. https://www.rfc-editor.org/info/rfc9114.
Krasic, C., M. Bishop, and A. Frindell, ed. 2022. QPACK: Field Compression
for HTTP/3. RFC 9204. RFC Editor. https://www.rfc-editor.org/info/rfc9204.
The static table of Appendix A and the field line representations of §4.5 are
implemented in src/http3/qpack.zig; §5 defines no Huffman code and defers to
RFC 7541, which is why that table is shared rather than transcribed twice.
Iyengar, J., and M. Thomson, eds. 2021. QUIC: A UDP-Based Multiplexed and
Secure Transport. RFC 9000. RFC Editor.
https://www.rfc-editor.org/info/rfc9000. What HTTP/3 runs on, implemented in
zig-quic rather than here — but its §2.1 stream types, §4.6 stream limits and
§19.19 application error codes all reach into src/http3.
Thomson, M., and S. Turner, eds. 2021. Using TLS to Secure QUIC. RFC 9001.
RFC Editor. https://www.rfc-editor.org/info/rfc9001. Why HTTP/3 has no
cleartext form: QUIC carries TLS and there is no version of it that does not,
so there is no h3c the way there is an h2c.
Cheshire, S., and M. Krochmal. 2013. Special-Use Domain Names. RFC 6761.
RFC Editor. https://www.rfc-editor.org/info/rfc6761. §6.2's .test, which
tests/fetch.zig uses for a name that resolves nowhere but where the test's
own resolver says.
Dusseault, L., and J. Snell. 2010. PATCH Method for HTTP. RFC 5789. RFC Editor. https://www.rfc-editor.org/info/rfc5789.
Féry, Sébastien. 2026. "Header Accordions: QPACK Amplification Attacks
against HTTP/3 Servers." FoxIO (blog), October 20, 2026.
https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers.
Why qpack.decodeBounded measures a field section decoded, at RFC 9114
§4.2.2's 32 octets a line, rather than by the length of its HEADERS frame,
and why the HTTP/3 client keeps no trailer section and accepts nothing after
one.
Fette, I., and A. Melnikov. 2011. The WebSocket Protocol. RFC 6455. RFC
Editor. https://www.rfc-editor.org/info/rfc6455. The opening handshake in
tests/e2e.zig is the worked example from §1.3. The protocol itself is
implemented in zig-websocket rather than here.
Fielding, R., M. Nottingham, and J. Reschke, eds. 2022. HTTP Semantics. RFC 9110. RFC Editor. https://www.rfc-editor.org/info/rfc9110.
———, eds. 2022. HTTP/1.1. RFC 9112. RFC Editor. https://www.rfc-editor.org/info/rfc9112.
Friedl, S., A. Popov, A. Langley, and E. Stephan. 2014. Transport Layer
Security (TLS) Application-Layer Protocol Negotiation Extension. RFC 7301.
RFC Editor. https://www.rfc-editor.org/info/rfc7301. How http_io.Client
chooses a version: §3.1 makes a server's answer optional, which is why a
handshake that selects nothing is spoken to in HTTP/1.1, and §3.2 has a server
select only what the client offered.
McManus, P. 2018. Bootstrapping WebSockets with HTTP/2. RFC 8441. RFC
Editor. https://www.rfc-editor.org/info/rfc8441. The :protocol
pseudo-header and SETTINGS_ENABLE_CONNECT_PROTOCOL, which are how
Response.upgrade works over HTTP/2.
Nottingham, M., P. McManus, and J. Reschke. 2016. HTTP Alternative
Services. RFC 7838. RFC Editor. https://www.rfc-editor.org/info/rfc7838.
The Alt-Svc field of §3 and its caching rules in §3.1, implemented in
src/io/AltSvc.zig, which http_io.Client uses to find HTTP/3 the way a
browser does. The ALTSVC frame of §4 and the Alt-Used field of §5 are not.
Nottingham, M., and R. Fielding. 2012. Additional HTTP Status Codes. RFC
6585. RFC Editor. https://www.rfc-editor.org/info/rfc6585. Where 431 Request Header Fields Too Large comes from.
Oku, K. 2017. An HTTP Status Code for Indicating Hints. RFC 8297. RFC
Editor. https://www.rfc-editor.org/info/rfc8297. 103 Early Hints, which is
what Response.sendInterim exists for on the server side, and what
client.Request.interim and FetchOptions.interim tell a client of.
Peon, R., and H. Ruellan. 2015. HPACK: Header Compression for HTTP/2. RFC
7541. RFC Editor. https://www.rfc-editor.org/info/rfc7541. The Appendix A
static table and the Appendix B Huffman code are transcribed in
src/http2/hpack/, and the Appendix C worked examples run as tests in
src/http2/hpack/rfc7541.zig.
Thomson, M., and C. Benfield, eds. 2022. HTTP/2. RFC 9113. RFC Editor. https://www.rfc-editor.org/info/rfc9113.
Hamilton, R. 2022. Bootstrapping WebSockets with HTTP/3. RFC 9220. RFC
Editor. https://www.rfc-editor.org/info/rfc9220. Three pages, most of them
about what it does not change from RFC 8441: the same extended CONNECT, the
same :protocol pseudo-header, over a transport that already has the streams
it needs. src/http3/Connection.zig advertises
SETTINGS_ENABLE_CONNECT_PROTOCOL on its strength.
Oku, K., and L. Pardue. 2022. Extensible Prioritization Scheme for HTTP. RFC
9218. RFC Editor. https://www.rfc-editor.org/info/rfc9218. Not implemented,
and named here because §5.3.2 of RFC 9113 deprecates the scheme it replaces:
src/http2/Connection.zig parses a PRIORITY frame only far enough to enforce
the one rule that outlived it, that a stream may not depend on itself.
Rescorla, E. 2018. The Transport Layer Security (TLS) Protocol Version 1.3.
RFC 8446. RFC Editor. https://www.rfc-editor.org/info/rfc8446. §4.6.1's
session tickets, which http3.Config holds the sealing key for; the protocol
itself is the transport's business rather than this library's.
Reschke, J. 2015. The 'Basic' HTTP Authentication Scheme. RFC 7617. RFC
Editor. https://www.rfc-editor.org/info/rfc7617. What http_io.proxy.Proxy.parse
turns the userinfo of a proxy URL into, for Proxy-Authorization.
Horton, M. R. 1983. Standard for Interchange of USENET Messages. RFC 850.
RFC Editor. https://www.rfc-editor.org/info/rfc850. The obsolete date form
src/protocol/date.zig accepts and never sends. §5.6.7 of RFC 9110 requires a
recipient to parse it, and its two-digit year is why it is obsolete.
Contributing
Issues and pull requests go to the Tangled mirror, at https://tangled.org/jcollie.dev/zig-http/issues and https://tangled.org/jcollie.dev/zig-http/pulls. CONTRIBUTING.md has what a change needs: the checks it has to pass, how tests and doctests are written, and how dependencies are moved.
License
MIT. The project follows the REUSE specification;
nix develop -c reuse lint checks it.