Showcase
One call, four carriers.
Four clients, one request, one shape of answer. Everything below was recorded from runs of this library against a local server: the code on the left is the code that ran, and the terminal shows what it printed.
The same call
The only line that differs between the four is the one that makes the connection. After that, the request, the reply and the body are handled by the same four methods.
let conn = @h1.connect(host, port~, tls=tls)
let (response, body) = @spec.call(
conn,
@spec.Request::new("POST", "/echo", body=@spec.Body::Bytes(b"abcdefghijkl")),
)
print(conn.kind(), response.status, body)
let conn = @h2.connect(host, port~, tls=tls)
let (response, body) = @spec.call(
conn,
@spec.Request::new("POST", "/echo", body=@spec.Body::Bytes(b"abcdefghijkl")),
)
print(conn.kind(), response.status, body)
let conn = @h3.connect(host, port~, tls=tls)
let (response, body) = @spec.call(
conn,
@spec.Request::new("POST", "/echo", body=@spec.Body::Bytes(b"abcdefghijkl")),
)
print(conn.kind(), response.status, body)
let ws = @ws.connect(url, tls=tls)
let stream = ws.open(@spec.Request::new("GET", "/chat"))
let status = ws.response(stream).status
ws.send_text("ping")
let reply = ws.next()
On the wire
What the four do to carry that one exchange. Step through it: each line is one thing the client put out or took in.
- connect — TCP, then a TLS 1.3 handshake offering
http/1.1; the server picks it. - head —
POST /echo HTTP/1.1withhosttaken from the connection, andcontent-length: 12. - body — the twelve octets, then nothing more: a declared length ends the request.
- reply head —
HTTP/1.1 200 OKwith its fields, read up to the blank line. - reply body — twelve octets by the same declared length, so the connection is ready for the next exchange.
- connect — a TLS 1.3 handshake offering
h2, then the prefacePRI * HTTP/2.0and this side'sSETTINGS. - open — a
HEADERSframe on stream 1 with:method,:path,:schemeand:authority, withoutEND_STREAM. - body — a
DATAframe with the twelve octets, thenEND_STREAMon stream 1. - reply head —
HEADERSwith:status 200on stream 1, routed to the exchange that opened it. - reply body —
DATA, the octets handed to the reader as they arrive, and each one credited back to the peer's window.
- connect — a QUIC handshake over UDP, TLS 1.3 inside it, offering
h3; the client's first datagrams carry it. - settings — a control stream declaring
SETTINGS, with the QPACK dynamic table shut on both sides. - open — a client-initiated bidirectional stream, a
HEADERSframe with the request fields encoded statically. - body — a
DATAframe on the same stream, and the stream's send side ended. - reply —
HEADERSthenDATAon that stream; frames on one stream keep their order, streams do not.
- connect — TCP and TLS 1.3, then the upgrade is an HTTP/1.1 request with
upgrade: websocketand a random key. - 101 — the accept field is checked against that key; anything else is refused, not used.
- text — a text frame, masked with a key drawn per frame; a whole message in one frame here.
- echo — the server's unmasked text frame, reassembled if it arrives in fragments.
- close — a close frame ends the read; its code and reason stay on the connection.
What differs, and what does not
h1 one exchange per connection, in order h2 many streams, one connection h3 many streams, one QUIC connection ws messages over an upgraded connection
open → Stream send(Stream, Bytes, end~) response(Stream) → Response read(Stream) → Bytes? trailers(Stream) → Array[Field]
is_ready is the one place the difference surfaces: false while an HTTP/1.1 exchange is in flight, true for the other three. A caller that keeps one connection per exchange can ignore it; a pool cannot, which is why the pool asks before it hands a connection out.
The runs behind this page drove the four adapters against a local server over real sockets, and the output above is copied from them rather than written by hand. The guide shows the code each tab stands for in full.