slate:nghttp2
HTTP/2: the framing layer, and HPACK on its own.
This module is behind the http2 build feature, which is on by default and is in every released
binary. It is a feature because nghttp2 has to be installed before slate will build at all — see
Building without a library. It
is the one feature that reaches into another module: a build without it leaves slate:http speaking
HTTP/1.1 alone, with the same three exports, the same request and the same reply forms.
import { h2Client, h2Server, h2Receive, h2Send, h2Next, h2Request, h2Respond, h2Close } from slate:nghttp2
DRAIN h2Next AFTER h2Send, NOT ONLY AFTER h2Receive. A session queues an event when it
writes as much as when it reads: the last frame of a request going out is what ends that stream, so
streamClose is waiting in the session the moment the write is made. A loop shaped as receive,
drain, write — which is the shape a socket callback naturally takes — leaves that event sitting
until the peer happens to send something else. Where the peer has nothing more to say, nothing ever
arrives to shake it out and the program waits forever.
It is the client still uploading when its answer comes back that meets this, because that is the one whose stream ends on a write rather than on a read. Alternate until neither side has anything: drain, write, drain again, and stop when a drain yields no event and a write yields no bytes.
Nothing here touches a socket. Bytes that arrived go in at h2Receive, bytes to write come out of
h2Send, and what happened in between is read off with h2Next. HTTP/2 is a transformation, so how
bytes reach the wire stays your program’s business — which is why the whole protocol can be driven
between two sessions in memory, with no port and nothing that can hang:
import { h2Client, h2Server, h2Receive, h2Send, h2Next, h2Request, h2Respond, h2Close } from slate:nghttp2
val c = h2Client()
val s = h2Server()
// Whatever one end wants to write, handed to the other.
pump(from, to)
val bytes = h2Send(from)
if bytes.length > 0 then h2Receive(to, bytes)
// Everything the peer did since last time.
seen(who)
var out = []
loop
val e = h2Next(who)
if e == null then break
push(out, e)
out
val stream = h2Request(c, { ":method": "GET", ":scheme": "https", ":authority": "example.test", ":path": "/things" })
pump(c, s)
for e in seen(s)
if e.kind == "headers" then print("asked for", e.headers[3])
h2Respond(s, stream, { ":status": "200", "content-type": "text/plain" }, "hello from h2")
pump(s, c)
for e in seen(c)
if e.kind == "data" then print(e.bytes is bytes, fromBytes(e.bytes).value)
h2Close(c)
h2Close(s)
asked for [":path", "/things"]
true hello from h2
Every run of bytes here is bytes — what h2Send hands over, the body a data event
carries, and a block out of hpackDeflate. Each of them goes straight to a socket or straight back
into a session, so a buffer is the shape both ends of that journey want; anything a program gives
in may be a buffer, an array of numbers or text.
h2Client(options?) / h2Server(options?) | a session |
h2Receive(session, bytes) | null, or { error } where the peer spoke nonsense |
h2Send(session) | the bytes to write, as bytes |
h2Next(session) | the next event, or null |
h2Request(session, headers, body?) | a client’s; answers the stream number |
h2Respond(session, stream, headers, body?) | a server’s |
h2RequestStream(session, headers) / h2RespondStream(session, stream, headers) | the same two with the body written afterwards |
h2Push(session, stream, piece) | more of a body that is still open; { error } where the peer gave up |
h2Finish(session, stream, trailers?) | the body is complete and the stream ends |
h2Settings(session, { … }) | |
h2Goaway(session, lastStream, code?) / h2Reset(session, stream, code) | giving up |
h2WindowUpdate(session, stream, by) / h2Consume(session, stream, bytes) | flow control |
h2Ping(session) | |
h2Wants(session) | { read, write } |
h2Close(session) | |
hpackDeflater(maxTable?) / hpackInflater() | HPACK on its own |
hpackDeflate(encoder, headers) / hpackInflate(decoder, bytes) | |
hpackClose(x) |
Which version a connection is
HTTP/2 over TLS is chosen by ALPN and by nothing else — no upgrade handshake, no version header.
That half is slate:net‘s: a listener offers a list, a client offers one, and
alpnProtocol(conn) says what the two settled on. A server taking both versions on one port asks it
and hands the connection to whichever it is:
val server = listen({ port: 8443, cert: pem, key: keyPem, alpn: ["h2", "http/1.1"] }, conn ->
if alpnProtocol(conn) == "h2"
speakHttp2(conn)
else
speakHttp1(conn))
h2c — HTTP/2 with no TLS at all — needs nothing extra, a session neither knowing nor caring
where its bytes come from. What is not here is the HTTP/1.1 Upgrade: dance that converts an existing
connection; prior-knowledge h2c, which is what one speaks to a service one controls, is the ordinary
case and works today.
Events
h2Next answers one at a time, in the order they happened, and null when there are no more. Every
event has a kind, and the rest of its fields depend on it:
kind | |
|---|---|
beginHeaders | stream, and the of its headers will carry — a header block has begun and its fields have not arrived |
headers | stream, headers, and of — "request", "response" or "trailer" |
headerListTooLarge | stream, of — in place of headers: the block was past the header list this end accepts, and its fields were not kept |
data | stream, bytes — one event per DATA frame, not one per stream |
streamEnd | stream — the peer will send no more, and may still be reading |
streamClose | stream, code — finished at both ends |
settings | the peer’s settings arrived and are in force |
goaway | lastStream, code |
rstStream | stream, code |
windowUpdate | stream, increment — stream is 0 for the connection |
ping | ack |
Events rather than callbacks, and that is the design decision this module turns on: nghttp2 calls
back from inside receive, which is a place your code has no business running — it cannot submit
frames there without reentering the library, and a closure it registered would have to be kept alive
by the session. What you write instead is a loop.
beginHeaders is for header-read deadlines. It comes at the HEADERS frame that opens a block —
trailers included, a PUSH_PROMISE not — and is always followed by that stream’s headers, a reset of
the stream, or the end of the session. A server starts a clock there and stops it at the headers, so
a peer dribbling one block’s CONTINUATION frames costs that stream (an h2Reset) rather than the
connection; slate:http does exactly that with its headMillis. A request refused before it is read
— past the hundred streams a session allows at once — gets no beginHeaders.
A header list is held to 64 KiB as it ARRIVES, and headerListTooLarge is what is left of one
past it. The size is RFC 9113’s — each field’s name and value in bytes, plus 32 — and 64 KiB is
what a server announces (SETTINGS_MAX_HEADER_LIST_SIZE) and what a client, which announces nothing,
is held to as well. The block is still decoded to its end, since the compression table has to stay
in step with the peer’s, but no field past the limit is kept: a peer can name a 4,000-byte field
again in one byte, so twenty kilobytes on the wire can spell eighty megabytes of headers, and none of
it is held. The stream stays open — the answer is the program’s. slate:http answers a request
431 without calling the handler, and fetch fails with a result.
import { h2Server, h2Receive, h2Next } from slate:nghttp2
frame(t, f, p) = [p.length >> 16, (p.length >> 8) & 255, p.length & 255, t, f, 0, 0, 0, 1, ...p]
val s = h2Server()
h2Receive(s, [...toBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n").toArray(), 0, 0, 0, 4, 0, 0, 0, 0, 0])
// A request whose one field is added to the table, then named again 20,000 times in a byte each.
var block = [131, 135, 132, 1, 9, ...toBytes("localhost").toArray(), 64, 1, 120, 127, 161, 30, ...toBytes("v".repeat(4000)).toArray()]
for i in 0..<20000
block.push(190)
h2Receive(s, [...frame(1, 1, block[0..<16384]), ...frame(9, 4, block[16384..<block.length])])
var e = h2Next(s)
while e != null
if e.kind != "settings" then print(e.kind, e.of ?? "-")
e = h2Next(s)
beginHeaders request
headerListTooLarge request
streamEnd -
Headers
Write them as an object, or as pairs where order and duplicates matter. An object is what a
program writes nine times in ten; two set-cookie headers are legal and an object has one key.
h2Respond(s, stream, { ":status": "200", "content-type": "application/json" }, body)
h2Respond(s, stream, [[":status", "200"], ["set-cookie", "a=1"], ["set-cookie", "b=2"]])
They always arrive as pairs. Two headers of one name are legal, and an object would silently keep one of them — which is the shape of defect that makes request smuggling possible. A program that wants an object folds the list itself, knowing what it discarded.
A third element marks a field never-indexed, which is a wire decision rather than a comment: an
attacker who can insert requests on a connection learns a secret header’s value one character at a
time by watching whether his guess compressed, and keeping it out of the dynamic table is the defence.
authorization and cookie are the fields that want it.
h2Request(c, [[":method", "GET"], [":scheme", "https"], [":path", "/"],
["authorization", "Bearer …", true]])
A header value is text and a number is refused rather than converted, because a value is a string
on the wire — and converting silently would make true a header value too:
import { h2Client, h2Request } from slate:nghttp2
h2Request(h2Client(), { ":path": 12 })
a header value is text
What a session refuses, and how
Bytes the peer got wrong are an ANSWER, because a peer that speaks nonsense is the ordinary thing a server deals with and a fault would take the server down with the connection:
import { h2Server, h2Receive } from slate:nghttp2
val s = h2Server()
print(h2Receive(s, toBytes("GET / HTTP/1.1\r\n\r\n")).error != "")
true
A handle you have closed is a defect in the program, exactly as a closed socket is, so that one faults — and says what was closed rather than naming an integer.
A body written a piece at a time
h2Request and h2Respond take the bytes they are to send, which is right for a body a program
already has. A body produced as it goes — an event stream, a proxied response, a file read in
pieces — is the other pair, and the difference on the wire is when the head goes out: a response
whose source never ends still has to say 200 before its first byte of body exists.
h2RespondStream(s, stream, { ":status": "200", "content-type": "text/event-stream" })
// … whenever there is something to say:
h2Push(s, stream, "data: hello\n\n")
// … and at the end, which for an event stream may be never:
h2Finish(s, stream)
h2Push answers { error } where the stream has no open body, which is the ordinary way a
program learns the peer gave up: a reset or a close drops the body, so the next push says so rather
than writing into nothing. Trailers may be named at h2Finish and nowhere else — what was unknown
until the body ended is exactly what a trailer is for.
h2RequestStream is the same on the client side, and it is what an upload of unknown length is.
Flow control, and back-pressure
A session extends its own windows as bytes arrive, which is right for a program that deals with a body
as fast as it reads one. A program that queues bodies, writes them to disk or hands them to something
slower wants the credit returned when the work is done — and that is what h2Consume is for. It
needs a session made for it, since a session that extends its own windows has nothing to report:
val s = h2Server({ autoWindowUpdate: false })
// … a body arrives, is written away, and only then:
h2Consume(s, stream, bytes.length)
HPACK on its own
The header-compression tier needs no session, and it is worth having by itself: it is the security-relevant half of HTTP/2, identical whoever drives the frames, and a decoder that trusts its input is a decompression bomb waiting for somebody to send it one.
import { hpackDeflater, hpackInflater, hpackDeflate, hpackInflate, hpackClose } from slate:nghttp2
val enc = hpackDeflater()
val dec = hpackInflater()
val block = hpackDeflate(enc, { ":status": "200", "content-type": "text/plain" })
print(hpackInflate(dec, block))
// The dynamic table is what makes the second block of the same headers a fraction of the first.
val again = hpackDeflate(enc, { ":status": "200", "content-type": "text/plain" })
print(again.length < block.length)
hpackClose(enc)
hpackClose(dec)
[[":status", "200"], ["content-type", "text/plain"]]
true
A deflater and an inflater are each a conversation, not a function. The dynamic table is built from
every block that has gone through, so blocks must be given to one inflater in the order they
arrived — that is HPACK’s design, and it is what makes the second request on a connection cost five
bytes where the first cost thirty-eight. A block that will not decode is an answer, { error }, for
the reason a session’s refusal is.
Where this sits
The module is named for the library and its names for the protocol, which is
slate:llhttp‘s arrangement exactly — that one is llhttp and exports httpParser and
httpFeed. What the two have in common is the tier, a door onto somebody else’s state machine, and
naming them the same way is what says so. It also leaves HTTP/2’s own short name free for whatever a
program eventually wants to import, which is the reason the HTTP server is slate:http and not
slate:llhttp.
slate:nghttp2 is slate:llhttp‘s sibling, and it is public for the same reason:
slate:http is the server a program wants, and these are the layers under whatever speaks
HTTP next. A server that takes both versions on one port, a client that keeps its connections, a
proxy, a gRPC endpoint — every one of those is an ordinary package rather than a change to slate.
The protocol itself is sh.sysl.nghttp2, the library
curl, Apache and Envoy use. slate adds no HTTP/2 of its own: what is here converts values at the
boundary and nothing else, which is the same arrangement slate:redis and slate:llhttp have with
their own bindings.