core.net.h3 — HTTP/3 + QPACK
A pure-Verum implementation of HTTP/3, QPACK header compression, and the extensible-priorities scheme:
| Spec | Title | Scope |
|---|---|---|
| RFC 9114 | HTTP/3 | Frame layer, SETTINGS, streams, requests, responses |
| RFC 9204 | QPACK: Field Compression for HTTP/3 | Static + dynamic tables, encoder and decoder state |
| RFC 9218 | Extensible Prioritization Scheme for HTTP | priority header / PRIORITY_UPDATE frame |
| RFC 7541 App B | Huffman table | Reused by QPACK string encoding |
core.net.h3 sits on top of core.net.quic. The QUIC
layer provides the streams; HTTP/3 frames flow inside them. Header
compression (QPACK) uses a pair of reserved unidirectional streams
per direction, exactly as the RFC prescribes.
Module map
| Concern | Module | Key types |
|---|---|---|
| Frame layer (DATA / HEADERS / SETTINGS / CANCEL_PUSH / …) | core.net.h3.frame | H3Frame, H3FrameError |
| Settings | core.net.h3.settings | H3Settings, H3SettingsId |
| Requests / responses | core.net.h3.request | H3Request, H3Response, H3Method |
| Client facade | core.net.h3.client | H3Client, ClientOptions, H3ClientError |
| Server facade | core.net.h3.server | H3Server, ServerOptions, H3Handler, H3ServerError |
| Connection driver | core.net.h3.connection | H3Connection, new_client, new_server, on_bidi_stream |
| Priority (RFC 9218) | core.net.h3.priority | Priority, Urgency, Incremental |
| QPACK encoder | core.net.h3.qpack.encoder | Encoder, EncoderInstruction |
| QPACK decoder | core.net.h3.qpack.decoder | Decoder, DecoderInstruction, HeaderField |
| QPACK static table (99 entries) | core.net.h3.qpack.static_table | STATIC_TABLE, lookup |
| QPACK dynamic table | core.net.h3.qpack.dynamic_table | DynamicTable, capacity + eviction |
| Huffman (RFC 7541 App B) | core.net.h3.qpack.huffman | encode, decode, canonical table |
| Errors | core.net.h3.error | H3Error, H3ErrorCode |
Client flow
mount core.net.h3.client.{H3Client, ClientOptions, H3ClientError};
mount core.net.h3.request.{H3Request};
mount core.time.duration.{Duration};
async fn fetch_example() -> Result<(), H3ClientError> {
// `ClientOptions` is a record with two constructors — `default()`
// and `with_system_trust()` — and NO builder methods. The knobs are
// set by record update.
let opts = ClientOptions {
idle_timeout: Duration.from_secs(30),
max_field_section_size: 65536_u64,
..ClientOptions.default()
};
let mut client = H3Client.connect(&"https://example.test/", opts).await?;
// Two ways in. `client.get(path)` / `client.post(path, ct, body)`
// are the short forms; `H3Request.get(authority, path)` plus
// `client.send(&req)` is the long one, and it is what you need as
// soon as a request carries its own headers.
let req = H3Request.get(client.authority(), Text.from("/api/users/42"))
.with_header(Text.from("accept"), Text.from("application/json"));
let response = client.send(&req).await?;
// `status`, `headers`, `body` and `trailers` are FIELDS of
// `H3Response`, not accessor methods.
let status: UInt16 = response.status;
for header in response.headers.iter() {
let _ = (header.name, header.value);
}
let body: List<Byte> = response.body;
let _ = (status, body);
Result.Ok(())
}
H3Client.connect performs QUIC connect + TLS 1.3 handshake + ALPN
negotiation + H3 unistream setup (control, qpack encoder, qpack
decoder) before returning. By the time client.send(&req) is called,
the connection is in 1-RTT and HEADERS frames can be issued
immediately. H3Request.get(authority, path) and
H3Request.post(authority, path, body) are the two request builders,
and .with_header(name, value) chains onto either. The client also
carries get / post / send / connect_resumed directly — they are
declared pub rather than public, which grammar/verum.ebnf:497
admits as the same visibility.
Server flow
mount core.net.h3.server.{H3Server, ServerOptions, H3Handler};
mount core.net.addr.{SocketAddr};
mount core.net.h3.request.{H3Request, H3Response, H3Method};
type MyHandler is { /* app state */ };
implement H3Handler for MyHandler {
async fn handle(&mut self, req: H3Request) -> H3Response {
match (req.method(), req.path().as_str()) {
(H3Method.Get, "/health") =>
// `ok` TAKES the body; there is no `with_body`.
H3Response.ok(Text.from("ok").as_bytes())
.with_header("content-type", "text/plain"),
_ => H3Response.status(404_u16),
}
}
}
async fn serve() -> Result<(), H3ServerError> {
let handler = MyHandler { /* ... */ };
// `ServerOptions` has no `default()` and no PEM builders. Its
// constructor takes a parsed chain and a signer; `with_alpn` and
// `with_idle_timeout` are the only builders.
let opts = ServerOptions.from_cert(cert_chain, signer)
.with_alpn(alpn_list);
// `SocketAddr.from_text`, not `"…".parse()`: `Text` has no `parse`
// method, and the call binds an unrelated free function taking
// `&Text` — it type-checks and panics at run time.
let addr = SocketAddr.from_text(&"0.0.0.0:443")?;
let server = H3Server.bind(&addr, opts).await?;
server.serve(handler).await
}
H3Response has two constructors, one builder and one converter —
four public methods, which is the list below. The sentence here said
"three constructors and one builder" and disagreed with its own list:
H3Response.ok(body: List<Byte>) -> H3Response // 200, body included
H3Response.status(code: UInt16) -> H3Response // any code, EMPTY body
resp.with_header(name: Text, value: Text) -> H3Response
resp.to_field_list() -> List<QpackHeaderField>
with_body, streaming and html do not exist. A body goes in through
ok; to give a non-200 response a body, update the record directly —
status, headers, body and trailers are public fields.
The H3Handler protocol is async fn(H3Request) -> H3Response; the
server handles connection setup, stream multiplexing, and QPACK
state in the background.
QPACK
QPACK is HPACK-for-HTTP/3 — header compression with the wrinkle that encoder state updates flow on a unidirectional stream so that the receiver can process header blocks out of order.
- Static table — 99 entries per RFC 9204 Appendix A. Constant lookups; always safe to reference by index.
- Dynamic table — bounded by
max_field_section_sizeandqpack_max_table_capacitynegotiated via SETTINGS. The decoder maintains "known-received" indices via the QPACK_DECODER stream; the encoder blocks on insertions whose acknowledgement hasn't been observed. - Huffman — RFC 7541 Appendix B table, reused verbatim. String literals are encoded Huffman-compressed when that strictly decreases byte count.
One session, both directions
There is no Encoder type and no Decoder type. QPACK is a single
QpackSession carrying both dynamic tables — yours and the peer's —
which is why its constructor takes two capacities:
mount core.net.h3.qpack.session.{QpackSession};
mount core.net.h3.qpack.{QpackHeaderField};
fn roundtrip(
headers: &List<QpackHeaderField>,
) -> Result<List<QpackHeaderField>, QpackDecodeError> {
let mut qp = QpackSession.new(0, 0); // own cap, peer cap
let wire = qp.encode_field_section(headers)?;
qp.decode_field_section(wire.as_slice())
}
The instruction streams are the session's too — drain_encoder_out,
drain_decoder_out, on_peer_encoder_bytes, on_peer_decoder_bytes —
and capacity is changed with set_encoder_capacity, not
set_max_capacity.
With dynamic table capacity 0 (the v0.1 default), the encoder runs in "static + literal" mode — no inter-stream coordination needed. This matches what every major HTTP/3 client negotiates today. Dynamic-table mode lands in a v1.1 followup and requires wiring the encoder's blocking/unblocking queue into the QUIC stream scheduler.
Known-answer test coverage
| Test | File |
|---|---|
| Static table — 99 entries + out-of-range rejection | vcs/specs/L2-standard/net/h3/qpack_static_table_coverage.vr |
| Huffman round-trip (RFC 7541 §C.4 + 256-byte stress) | vcs/specs/L2-standard/net/h3/qpack_huffman_roundtrip.vr |
| Encoder/decoder round-trip (4 representation variants) | vcs/specs/L2-standard/net/h3/qpack_encoder_decoder_roundtrip.vr |
Priorities
core.net.h3.priority parses the priority header (RFC 9218
§4) and the PRIORITY_UPDATE frame (§6). Priorities carry two
parameters:
u— urgency, integer 0–7 (0 is highest), default 3.i— incremental, boolean, defaultfalse. Whentrue, the server can interleave DATA chunks from multiple concurrent requests at the same urgency.
// The type is `H3Priority`, and `urgency` / `incremental` are FIELDS,
// not accessor methods. There is no `Urgency` type — urgency is a
// `UInt8` in 0..7 (0 = highest, default 3) per RFC 9218 §4.1.
mount core.net.h3.priority.{H3Priority, parse_header};
// `parse_header` is a FREE function and takes the raw header VALUE as
// bytes, not a Text — there is no `H3Priority.parse`.
let p = parse_header(&"u=1, i".as_bytes())
.unwrap_or(H3Priority.default());
let _ = (p.urgency, p.incremental); // UInt8, Bool
Priorities are hints — the server-side scheduler uses them to order outbound DATA frames but is not obligated to obey (per §5).
Refinement contracts
H3 invariants (the bulk of H3 is byte-level shuffling, but the QPACK dynamic table carries one):
| Invariant | Module |
|---|---|
| Dynamic table size ≤ negotiated capacity | qpack.dynamic_table |
| HEADERS frame Huffman tree well-formed (RFC 7541 Appendix B) | qpack.huffman |
| Control stream id < 4 (reserved) | connection |
Deep-dive pages
- Frames (RFC 9114 §7) — all 7 frame types byte-exact, control-vs-request legality, SETTINGS identifiers.
- QPACK (RFC 9204) — static + dynamic table, prefixed-integer codec, Huffman, encoder / decoder stream instructions, field sections.
- Priority (RFC 9218) — H3Priority, PRIORITY_UPDATE frames, Structured-Header parsing.
- Server push (§4.6 + §7.2.5) — PushEmitter, push_id lifecycle, MAX_PUSH_ID / CANCEL_PUSH.
Status
Client + server facades ship. QPACK baseline (max_table_capacity=0)
is production-ready; dynamic-table encoder extensions are scaffolded
for v1.1. The L2 conformance suite: 24/24 byte-exact + surface
tests covering every frame type, full QPACK instruction + field
section encoding, priority parsing, and the push emitter lifecycle.
The RFC 9204 Huffman codec passes byte-for-byte against the
canonical HPACK table (rfc9204_qpack_huffman).
See also
core.net.quic— the transport HTTP/3 sits on.core.net.tls13— TLS 1.3 handshake consumed by QUIC.core.net.weft— middleware (circuit breakers, retries, rate limiters) sits above H3 on the server side.