moonhttp

The HTTP family for MoonBit: the formats a request and a response are written in, each a package of its own. Bytes in, events out; no sockets.

CItestsGitHublicense
$moon add moonbitstack/moonhttp

@form

A request body read as the form its Content-Type says it is: multipart/form-data through moonmime, application/x-www-form-urlencoded through moonurl.

fn
fn parse( body : BytesView, content_type : StringView, limits~ : @moonmime.Limits = @moonmime.limits, strict~ : Bool = false) -> @moonmime.Form raise @moonmime.Refused

Read a request body as the form its Content-Type says it is: Go's Request.ParseForm, and Starlette's Request.form(), without the request. Any other content type, or a multipart one with no boundary to split on, carried no form, and the answer is an empty one; strict raises NotAForm instead. limits bounds both encodings, its parts counting pairs in a urlencoded body.

@range

RFC 9110 §14 range requests: the fields, the arithmetic against a length, and the multi-range body.

let
let unit : String = "bytes"

The only range unit HTTP defines (RFC 9110 §14.1.2). It is a parameter everywhere rather than a constant because §14.1 leaves the registry open, and a reader that hard-coded bytes would silently mis-handle a unit it was never told about instead of ignoring it.

let
let limit : Int32 = 200

How many ranges one request may ask for before the answer is to send the whole thing instead (RFC 9110 §14.2). A request may name any number of ranges, and a server that honours all of them does a great deal of seeking for one client — the shape of the 2011 Apache range attack. 200 is Apache's own MaxRanges default.

enum
enum Spec {
  /// `first-last`: both positions, inclusive.
  Between(Int64, Int64)
  /// `first-`: from here to the end, however long it turns out to be.
  From(Int64)
  /// `-suffix`: the last so many octets.
  Last(Int64)
} derive(Eq, Debug)

One range as a client asked for it, before it is measured against a length (RFC 9110 §14.1.1). The three shapes are the grammar's: an int-range with both ends, an int-range with only a first position, and a suffix-range. They stay separate because they mean different things — From(0) asks for everything, while Last(0) asks for nothing and is unsatisfiable.

struct
struct Part {
  at : Int64
  last : Int64
} derive(Eq, Compare, Debug)

One range measured against a representation: the first and last octet, both inclusive (RFC 9110 §14.1.2). Inclusive because the field is, and converting at the edge is where off-by-one errors come from. [Part::length] gives the count for a caller who wants one.

fn
fn Part::length(self : Part) -> Int64

How many octets this part covers.

fn
fn decode(value : StringView, unit~ : String = unit) -> Array[Spec]?

Read a Range field value (RFC 9110 §14.2). None means the field is to be ignored, which §14.2 requires for a unit the server does not implement and for a value that does not parse. Ignoring it is not an error: the answer is the whole representation, with status 200. An empty range-set is not a range request either — bytes= names no range, and serving everything is the right reading of it.

fn
fn encode(specs : ArrayView[Spec], unit~ : String = unit) -> String

Write a Range field value (RFC 9110 §14.2).

fn
fn measure( specs : ArrayView[Spec], length~ : Int64, merge~ : Bool = true, limit~ : Int32 = limit) -> Array[Part]?

Measure what was asked against a representation of length octets (RFC 9110 §14.1.2). None is unsatisfiable, and the answer to it is 416 with the [unsatisfied] Content-Range — not 200, and not an empty 206. A request is unsatisfiable only when *no* spec in it is satisfiable; §14.1.1 says one good spec carries the field, and the ones that cannot be met are dropped. merge coalesces ranges that overlap or touch, which §14.2 permits and which keeps bytes=0-100,50-150 from sending the middle twice. limit caps how many ranges survive; past it the caller should send the whole representation, so exceeding it reads as "no ranges here" rather than as an error.

struct
struct Content {
  unit : String
  part : Part?
  complete : Int64?
} derive(Eq, Debug)

A Content-Range field value (RFC 9110 §14.4): where a part sits in the whole, or — in a 416 — only how big the whole is. part is absent in the */complete form a 416 carries. complete is absent in the part/* form, which says the total length was not known when the field was written.

fn
fn Content::new( part : Part, complete? : Int64, unit~ : String = unit) -> Content

The Content-Range of a part that was served (RFC 9110 §14.4). complete is the length of the whole representation. Leaving it out writes *, which §14.4 reserves for a sender that does not know the total — a generated response being streamed, say. Give it when it is known: a client recombining parts needs it.

fn
fn unsatisfied(complete : Int64, unit~ : String = unit) -> Content

The Content-Range a 416 carries: bytes */1234 (RFC 9110 §14.4). It names no part because there is none to name; the number is what the client needs in order to ask again for something that exists.

fn
fn Content::encode(self : Content) -> String

Write a Content-Range field value (RFC 9110 §14.4).

fn
fn Content::decode(value : StringView) -> Content?

Read a Content-Range field value (RFC 9110 §14.4). None is a value that cannot be trusted, and §14.4 is explicit about what follows: a recipient must not recombine content with a stored representation on the strength of an invalid Content-Range. So a last position before the first, or a complete length that does not reach past the last position, is refused here rather than repaired.

fn
fn accept(ranges~ : Bool = true, unit~ : String = unit) -> String

The Accept-Ranges field value (RFC 9110 §14.3). none is not the absence of the field: it says outright that range requests will not be honoured, which stops a client from trying and failing.

fn
fn multipart(boundary : StringView) -> String

The Content-Type a multi-range 206 carries (RFC 9110 §14.6).

fn
fn parts( parts : ArrayView[(Part, Bytes)], boundary~ : String, kind~ : String, complete? : Int64, unit~ : String = unit) -> Bytes

The body of a multi-range 206 (RFC 9110 §14.6): one part per range, each with its own Content-Type and Content-Range, then a closing boundary. parts pairs each range with its octets — this package does no reading, so the caller fetches them. kind is the representation's own media type, which §14.6 repeats in every part because each part is a document in its own right.

@conditional

RFC 9110 §8.8 and §13: entity-tags, the five precondition fields, and the order §13.2.2 weighs them in.

struct
struct Tag {
  text : String
  weak : Bool
} derive(Eq, Debug)

An entity-tag (RFC 9110 §8.8.3). text is what is inside the quotes; keeping them would put the field's syntax inside the value. weak is the W/ in front. Two weak tags mark representations that are equivalent, not identical — enough for "has this changed", not enough for "may I splice these two ranges".

enum
enum Tags {
  Any
  These(Array[Tag])
} derive(Eq, Debug)

What a precondition field names (§13.1.1): every representation, or these tags. * is not a tag that matches everything. §13.1.1 and §13.1.2 both read it as "does a representation exist", and answer in opposite directions.

enum
enum Validator {
  Marked(Tag)
  Dated(@moondate.Moment)
} derive(Eq, Debug)

Either kind of validator (§8.8). If-Range takes one or the other (§13.1.5); the other four fields each take one kind.

enum
enum Verdict {
  /// No precondition failed: carry on with the method.
  Go
  /// The client's copy is still good — 304, with no body (§15.4.5).
  Fresh
  /// A precondition failed — 412 (§15.5.13).
  Failed
} derive(Eq, Debug)

What §13.2.2 says to do with the request once the preconditions are weighed.

fn
fn Tag::new(text : String, weak~ : Bool = false) -> Tag

A tag with this text.

fn
fn Tag::same(self : Tag, other : Tag) -> Bool

Strong comparison (§8.8.3.2): neither is weak and the texts match. If-Match and If-Range use this one — both are about replacing or splicing octets, which a weak tag does not promise.

fn
fn Tag::alike(self : Tag, other : Tag) -> Bool

Weak comparison (§8.8.3.2): the texts match, weakness ignored. If-None-Match uses this one — a weak tag can answer "has it changed".

fn
fn hash( raw : BytesView, digest~ : &@spec.Hash, weak~ : Bool = false) -> Tag

An entity-tag from the octets themselves: their count and their digest, both in hexadecimal. Strong, which is the point of hashing the content. digest is a parameter — which hash to spend on every response is the server's trade. The shape is the etag package Express serves static files with, "<length>-<digest>", in nginx and Apache's hexadecimal.

fn
fn stamp( length~ : Int64, modified~ : @moondate.Moment, weak~ : Bool = true) -> Tag

An entity-tag from what a file system knows without opening the file: when it last changed and how long it is. nginx's "<mtime>-<length>", one stat. Weak, as a one-second timestamp must be: two writes inside the same second leave both numbers unchanged.

fn
fn Tag::encode(self : Tag) -> String

Write the tag as a field value: "x", or W/"x" (§8.8.3).

fn
fn Tag::decode(text : StringView) -> Tag?

Read one entity-tag. None when it is not one — usually an unquoted value, which §8.8.3 has no room for.

fn
fn Tags::encode(self : Tags) -> String

Write a whole field value: *, or the tags separated by commas.

fn
fn Tags::decode(text : StringView) -> Tags?

Read a whole field value. Commas are found outside the quotes: , is in the etagc set (§8.8.3), so a tag may contain one. None when any member is not an entity-tag — §13.1.1 has no reading for half a list.

fn
fn Validator::decode(text : StringView) -> Validator?

Read an If-Range value (§13.1.5). The first two characters tell them apart, as §13.1.5 says: only an entity-tag starts with a quote, only a weak one with W/.

fn
fn Validator::encode(self : Validator) -> String raise @moondate.Refused

Write an If-Range value. Raises for a moment with no zone — an HTTP-date is GMT by definition (§5.6.7).

struct
struct Ask {
  /// `If-Match` (§13.1.1).
  matching : Tags?
  /// `If-None-Match` (§13.1.2).
  none_matching : Tags?
  /// `If-Modified-Since` (§13.1.3).
  modified_since : @moondate.Moment?
  /// `If-Unmodified-Since` (§13.1.4).
  unmodified_since : @moondate.Moment?
  /// `If-Range` (§13.1.5).
  ranging : Validator?
} derive(Eq, Debug)

The five precondition fields of one request (§13.1), already read.

let
let ask : Ask =

A request that carried no preconditions at all.

fn
fn Ask::new( matching? : Tags, none_matching? : Tags, modified_since? : @moondate.Moment, unmodified_since? : @moondate.Moment, ranging? : Validator) -> Ask

The preconditions a request carried, named one at a time.

fn
fn Ask::read(headers : ArrayView[(String, String)]) -> Ask

Pull the five fields out of a request's headers. Names match without regard to case (§5.1). Two lines of one list-valued field are one list (§5.3), so the tag fields are joined before reading; the date fields are not lists, so a repeat is ignored. A field present but unreadable comes back absent, per §13.1.3 and §13.1.4.

fn
fn Ask::evaluate( self : Ask, verb~ : String, etag? : Tag, modified? : @moondate.Moment, present~ : Bool = true) -> Verdict

Weigh the preconditions in the order §13.2.2 sets out. verb is the request method (method is reserved). It decides what a failed If-None-Match means: 304 for a GET or HEAD, 412 otherwise. No default — guessing GET would tell a PUT its copy is current. etag and modified are what the server holds; present is whether it holds anything at all, which is all * asks.

fn
fn Ask::unchanged( self : Ask, etag? : Tag, modified? : @moondate.Moment) -> Bool

Whether If-Range still holds, and so whether a Range may be honoured (§13.1.5). Absent holds: no condition, nothing in the way. Both kinds compare strongly, and a weak tag is refused rather than compared — "may these octets be spliced onto yours" is what a weak tag may not answer.

@sse

WHATWG Server-Sent Events, framed both ways and dispatched the way an EventSource does.

struct
struct Event {
  data : String
  /// The type an `addEventListener` binds to. Absent means `message`.
  kind : String?
  /// Sets the client's `lastEventId`, which it sends back on reconnection.
  id : String?
  /// How long the client should wait before reconnecting, in milliseconds.
  retry : Int32?
  /// A `:`-prefixed line. It dispatches nothing and exists to keep a connection
  /// warm through a proxy that would otherwise close it.
  comment : String?
} derive(Eq, Debug)

One event on the wire. A field left None is left out of the frame. data carrying newlines goes out as several data: lines and comes back joined, which is the format's own rule and the reason a payload with a blank line in it cannot be sent as one.

fn
fn Event::of(data : String) -> Event

An event carrying only data, which is the common case.

fn
fn Event::new( data~ : String = "", kind? : String, id? : String, retry? : Int32, comment? : String) -> Event

An event with whichever fields are wanted.

fn
fn Event::ping(comment~ : String = "") -> Event

A comment-only frame: nothing is dispatched and the connection stays warm.

fn
fn Event::encode(self : Event, space~ : Bool = true) -> String

The frame this event is sent as, ending in the blank line that dispatches it. space writes the one optional space after each colon, which every stream in the wild carries and which a reader strips again. Turning it off saves a byte per field on a stream where that matters, and changes nothing a client sees.

fn
fn decode(input : StringView) -> Array[Event]

Read a stream of frames. Anything the format does not define is ignored rather than refused — an unknown field name, a line with no colon, a stray carriage return. That is what the specification says to do, and it is what lets the format grow. A trailing partial frame, one not yet closed by a blank line, is left out: it has not been dispatched and is not an event yet.

fn
fn events(input : StringView) -> Array[Event]

The events an EventSource dispatches from a stream: what a browser hands its listeners, where [decode] answers the frames as they were written. Three rules differ, all from the specification's "Interpreting an event stream". A frame with no data: line dispatches nothing, so a comment or a lone retry: is not an event. The type is always set, message when the frame named none or named the empty string. And id and retry are the stream's, not the frame's: each holds the last value the stream set until a later line changes it, which is what a client sends back as Last-Event-ID and waits before reconnecting. A trailing frame not yet closed by a blank line is left out, as in decode.

let
let media_type : String = "text/event-stream"

The media type an event stream is served as.

@ws

RFC 6455 §5 WebSocket framing: opcodes, masking, reassembly, close statuses.

suberror
suberror Refused {
  /// The peer broke the framing: a set RSV bit with no extension negotiated, a reserved
  /// opcode, a client frame that is not masked, a control frame that is fragmented or
  /// longer than 125 bytes, a continuation with no message in progress, or a new data
  /// frame before the last one finished.
  Protocol(String)
  /// A text message or close reason whose bytes are not UTF-8 (§8.1), or a close code
  /// §7.4.1 does not allow on the wire.
  Payload(String)
  /// The peer announced or sent more than this endpoint accepts.
  Exceeded(limit~ : Int32, got~ : Int64)
} derive(Eq, Debug)

A protocol violation, carrying the status the connection must then be failed with (RFC 6455 §7.4.1, §7.1.7). This is not end of stream: a peer that simply stops sending has not violated anything, and the connection closes without a status.

fn
fn Refused::status(self : Refused) -> Int32

The §7.4.1 status a violation is closed with: 1002 for framing, 1007 for payload, 1009 for size.

let
let limit : Int32 = 16 * 1024 * 1024

How many bytes of payload this endpoint accepts, in one frame and in a message reassembled from many. Sixteen mebibytes is uvicorn's --ws-max-size. The ceiling has to bound the reassembled message and not only each fragment, because a message split into enough small frames would otherwise pass every per-frame check and still exhaust memory.

enum
enum Opcode {
  Continuation
  Text
  Binary
  Close
  Ping
  Pong
} derive(Eq, Debug)

A frame opcode (RFC 6455 §5.2): the three data opcodes and the three control ones.

fn
fn Opcode::code(self : Opcode) -> Int32

The four-bit value on the wire.

fn
fn Opcode::of(code : Int32) -> Opcode?

The opcode a wire value names, or None when §5.2 reserves it.

fn
fn Opcode::is_control(self : Opcode) -> Bool

Whether this is a control opcode, which §5.5 holds to tighter rules than a data frame: at most 125 bytes of payload, and never fragmented.

struct
struct Frame {
  fin : Bool
  opcode : Opcode
  payload : Bytes
} derive(Eq, Debug)

A frame (RFC 6455 §5.2): its FIN bit, its opcode, and its payload already unmasked.

fn
fn Frame::encode(self : Frame, mask~ : Bytes = b"") -> Bytes

A frame on the wire (RFC 6455 §5.2). A four-byte mask sets the MASK bit and masks the payload. A client must mask every frame and a server must mask none (§5.1), and the key has to be unpredictable, so it comes from the caller: this package has no randomness and should not pretend to. The length takes the shortest of the three forms — seven bits up to 125, sixteen bits under 126, sixty-four under 127.

fn
fn check( b0 : Int32, b1 : Int32, len : Int64, client~ : Bool, limit~ : Int32 = limit) -> Opcode raise Refused

Check a frame's first two bytes and its announced length, answering with the opcode. client says the frames being read came from a client, whose frames must be masked (§5.1); a server's must not be, and reading those takes client=false. Run this before reading the payload. It is the length check that keeps a sixty-four bit length from being truncated into a short allocation, and keeps an honest but enormous one from being allocated at all — the sign bit included, which §5.2 reserves.

fn
fn decode( b : BytesView, client~ : Bool = true, limit~ : Int32 = limit) -> (Frame, Int32)? raise Refused

One frame at the start of b, with its payload unmasked, and how many bytes it took. None means b does not hold a whole frame yet, so a caller feeds it more and asks again. A frame that breaks the rules is refused rather than answered None, so a violation is never mistaken for a short buffer.

enum
enum Message {
  Text(String)
  Binary(Bytes)
  Close(status~ : Int32, reason~ : Bytes)
} derive(Eq, Debug)

A complete application message: text with its payload decoded, binary with its bytes, or a close with its status and reason. Control frames do not surface here — a Reader answers a ping itself — so an application sees what it sent and received and nothing of the protocol's upkeep. Text carries a String rather than bytes because §8.1 makes invalid UTF-8 a 1007 failure: bytes that would not decode never get this far, so nothing hands an application a message the peer did not send.

fn
fn close(status : Int32, reason~ : String = "") -> Bytes

A close frame's payload (RFC 6455 §5.5.1): the status big-endian, then the reason.

fn
fn status(payload : BytesView) -> Int32

The status a close payload carries, or 1005 when it carries none (§7.1.5). 1005 is a local sentinel and must never be sent: a close that came with no status is echoed with no status, not with this number.

fn
fn reason(payload : BytesView) -> Bytes

The reason a close payload carries — everything after the two status bytes.

fn
fn allowed(status : Int32) -> Bool

Whether a status may appear on the wire (RFC 6455 §7.4.1 and the IANA registry). 1004 is reserved, and 1005, 1006 and 1015 are sentinels an endpoint sets locally — a peer that sends one has broken the protocol. 1012 through 1014 were registered after RFC 6455 and are allowed here, because refusing a registered status would break a real deployment for the sake of a closed reading of §7.4.1.

struct
struct Step {
  message : Message?
  reply : Frame?
} derive(Eq, Debug)

What a frame made of the connection: a message when it finished one, and a frame to send back when the protocol owes one. A ping owes a pong, and a close owes a close (§5.5.1). The reply comes back as a frame rather than as bytes because a client has to mask what it sends and only the caller has the randomness to do it with.

struct
struct Reader {
  payload : Buffer
  mut kind : Opcode?
  limit : Int32
}

The state of one direction: what has been assembled so far, and of what kind.

fn
fn Reader::new(limit~ : Int32 = limit) -> Reader

A reader with nothing assembled.

fn
fn Reader::pending(self : Reader) -> Int32

How many bytes of the message in progress have been assembled.

fn
fn Reader::feed(self : Reader, frame : Frame) -> Step raise Refused

Take one frame (RFC 6455 §5.4 for fragments, §5.5 for the control frames interleaved with them). A ping is answered with a pong carrying the same payload, a pong is dropped, and a close is echoed and then reported. A data frame extends or finishes the message.

@upgrade

RFC 6455 §4, the opening handshake — both sides, and the proof that makes the difference.

let
let guid : String = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

The string appended to a client's key before hashing (RFC 6455 §4.2.2). It is there so that a server which merely echoes what it was sent cannot pass for one that understood the protocol — a cached HTTP proxy, say.

fn
fn accept(key : String) -> String

The Sec-WebSocket-Accept for a client's Sec-WebSocket-Key: base64 of the SHA-1 of the key with the GUID appended (RFC 6455 §4.2.2).

fn
fn key(random : BytesView) -> String

The Sec-WebSocket-Key for sixteen random bytes (RFC 6455 §4.1). The randomness is the caller's: the key has to be unpredictable to the server, and a library with no entropy of its own should not invent any. A key of another length is what the caller asked for and is encoded as given.

fn
fn subprotocol( offered : ArrayView[String], supported : ArrayView[String]) -> String?

The first subprotocol the client offered that this endpoint supports, or None (RFC 6455 §4.2.2). The client's order is its preference, so it is the one that decides.

fn
fn request( host~ : String, key~ : String, path~ : String = "/", protocols~ : ArrayView[String] = [][:], origin? : String, extra~ : ArrayView[(String, String)] = [][:]) -> Bytes

A client's opening handshake (RFC 6455 §4.1): the GET that asks for the upgrade. key comes from key, over bytes the caller drew. protocols and origin are written only when given, because §4.1 makes both optional and a server may treat an empty list as a request for a subprotocol it cannot provide.

fn
fn response( key : String, protocol? : String, extra~ : ArrayView[(String, String)] = [][:]) -> Bytes

The 101 Switching Protocols that completes the handshake (RFC 6455 §4.2.2): the upgrade headers, the proof, and the one subprotocol the server selected.

suberror
suberror Refused {
  /// `Upgrade` or `Connection` does not ask for a WebSocket (§4.2.1).
  NotAnUpgrade(String)
  /// A version other than 13, which §4.4 answers with `Sec-WebSocket-Version: 13`.
  Version(got~ : String)
  /// `Sec-WebSocket-Key` is missing, or not sixteen bytes of base64 (§4.2.1).
  Key(String)
  /// The server's `Sec-WebSocket-Accept` is not the proof for the key that was sent.
  Proof(want~ : String, got~ : String)
} derive(Eq, Debug)

Why a handshake was not accepted, so a server can answer with the right status and a client can say what the server got wrong.

fn
fn read_request( headers : ArrayView[(String, String)]) -> (String, Array[String]) raise Refused

Read a client's handshake headers and answer with its key and the subprotocols it offered (RFC 6455 §4.2.1). Header names are matched without regard to case, and Connection is read as the comma-separated list §4.2.1 allows it to be, because a proxy may have added to it. A version other than 13 is refused, which is what §4.4 asks a server to answer.

fn
fn read_response( key : String, headers : ArrayView[(String, String)]) -> Unit raise Refused

Check a server's response headers against the key that was sent (RFC 6455 §4.1). A client that skips this accepts an upgrade from anything that can write 101, which is the case §4.2.2's proof exists to rule out.

@huffman

RFC 7541 Appendix B, the Huffman code HPACK and QPACK both use.

suberror
suberror Refused {
  Eos
  Padding(String)
  Malformed(String)
} derive(Eq, Debug)

Why a Huffman string was refused: the EOS symbol appeared in the input, the padding is not a run of fewer than eight one-bits, or the bits leave the code space (RFC 7541 §5.2). All three are what §5.2 calls a decoding error, and a receiver treats them the same way; they are separate here so a log says which.

fn
fn encode(input : BytesView) -> Bytes

Huffman-encode input (RFC 7541 §5.2): each octet becomes its code, and the final partial octet is padded with the most-significant bits of the EOS code (all ones). The inverse of decode for valid inputs.

fn
fn size(input : BytesView) -> Int32

The number of octets input occupies when Huffman-encoded, without building the output — used to choose the shorter of raw vs. Huffman string literals.

fn
fn decode(input : BytesView) -> Bytes raise Refused

Huffman-decode input (RFC 7541 §5.2). Raises HuffmanError if the input contains the EOS symbol, if the trailing padding is not a run of fewer than 8 one-bits, or if the bit stream leaves the code space. The inverse of encode.

@hpack

RFC 7541 HTTP/2 header compression: both tables, both ends.

suberror
suberror Refused {
  Malformed(String)
  Index(got~ : Int32)
  Exceeded(limit~ : Int32, got~ : Int32)
} derive(Eq, Debug)

Why a header block was refused (RFC 7541 §6). Malformed and Index are connection errors in HTTP/2 (RFC 9113 §4.3): the two ends' dynamic tables have gone out of step and nothing after this point can be read. Exceeded is not. The block was decoded to its end, so the table is in step, and only this one field section is refused for being larger than the caller allows.

let
let limit : Int32 = 4096

How many octets a dynamic table holds by default. Four kibibytes is the value HTTP/2 gives SETTINGS_HEADER_TABLE_SIZE before either end has said otherwise (RFC 9113 §6.5.2), so it is what both tables start at.

fn
fn entry(index : Int32) -> @header.Header?

Look up an HPACK static-table entry by its 1-based RFC index (1..=61), returning (name, value) or None when the index is out of range.

fn
fn string(octets : BytesView, huffman~ : Bool = true) -> Bytes

A string literal (RFC 7541 §5.2): the length as a seven-bit-prefix integer, then the octets, with the H bit saying whether they are Huffman-coded. huffman takes the shorter of the two forms, which is what an encoder does; false writes the octets as they are, which is what a test with a published block needs. A tie goes to the coded form, which is what RFC 7541's own examples do: it costs nothing and leaves less of the value legible to anything reading the connection.

fn
fn read_literal( data : BytesView, offset : Int32) -> (Bytes, Int32) raise Refused

Decode an HPACK string literal from data at offset, returning (octets, bytes_consumed). The length is read as a 7-bit-prefix integer (the H bit is masked off); this is the inverse of string for H = 0. Huffman decoding is not applied here, so ask @prefix.flagged first if the literal may be coded.

let
let static_table : Array[@header.Header] =

The static table as (name, value) octet pairs, in RFC index order (index 1 at position 0). Derived once from static_table.

fn
fn read_string( data : BytesView, offset : Int32) -> (Bytes, Int32) raise Refused

Read an HPACK string literal at offset, resolving Huffman coding when the H bit is set, returning (octets, bytes_consumed). Unlike read_literal, this applies Huffman decoding. Raises on bad Huffman.

struct
struct Table {
  mut entries : Array[(Bytes, Bytes)]
  mut size : Int32
  mut limit : Int32
}

The HPACK dynamic table: a FIFO of recently seen (name, value) entries, newest first (entries[0]), bounded by limit octets where each entry costs name.len + value.len + 32 (RFC 7541 §4.1). Adding evicts the oldest entries until the newcomer fits; an entry larger than limit empties the table and is not stored (§4.4).

fn
fn Table::new(limit~ : Int32 = limit) -> Table

A new empty dynamic table bounded by limit octets (default 4096, the HTTP/2 initial SETTINGS_HEADER_TABLE_SIZE).

fn
fn Table::resize(self : Table, limit : Int32) -> Unit

Resize the table (a dynamic table size update, RFC 7541 §4.2), evicting to fit.

fn
fn Table::add(self : Table, name : Bytes, value : Bytes) -> Unit

Insert (name, value) at the front, evicting oldest entries to make room. If the entry alone exceeds limit the table ends up empty (RFC 7541 §4.4).

fn
fn Table::get(self : Table, index : Int32) -> @header.Header?

The field an HPACK index names, across the static and dynamic tables together (RFC 7541 §2.3.3): one through sixty-one are the static table, and everything above is this table, newest first. None when the index names nothing.

fn
fn Table::limit(self : Table) -> Int32

The ceiling this table evicts to keep under, in octets.

fn
fn Table::count(self : Table) -> Int32

The number of entries currently in the dynamic table.

fn
fn Table::size(self : Table) -> Int32

The current total size of the dynamic table in octets (§4.1 accounting).

fn
fn size_update(limit : Int32) -> Bytes

Encode a dynamic table size update (RFC 7541 §6.3): 001 prefix with the new maximum size as a 5-bit-prefix integer.

struct
struct Decoder {
  table : Table
  mut limit : Int32
}

A stateful HPACK decoder: it owns a dynamic table that persists across the header blocks of a connection. limit is the peer-agreed hard cap (SETTINGS_HEADER_TABLE_SIZE) a size update may not exceed.

fn
fn Decoder::new(limit~ : Int32 = limit) -> Decoder

A new decoder whose dynamic table is bounded by limit octets (also the hard cap enforced on dynamic table size updates).

fn
fn Decoder::decode( self : Decoder, block : BytesView, limit? : Int32) -> Array[@header.Header] raise Refused

Decode one complete header block into its header list (RFC 7541 §6), mutating the dynamic table for incrementally indexed fields and size updates. limit bounds the list by RFC 7541 §4.1 size, each field's octets plus 32, which is what SETTINGS_MAX_HEADER_LIST_SIZE counts. Past it the block is still decoded to its end, so the dynamic table stays in step with the encoder, but no more fields are kept, and then Exceeded is raised. A block of a few octets that names large table entries over and over would otherwise decode to as much memory as the peer liked.

struct
struct Encoder {
  table : Table
  mut huffman : Bool
}

A stateful HPACK encoder: it owns a dynamic table mirroring the decoder's, and prefers indexed representations. huffman selects Huffman string literals when they are shorter.

fn
fn Encoder::new(limit~ : Int32 = limit, huffman~ : Bool = true) -> Encoder

A new encoder bounded by limit octets; huffman (default true) enables the shorter-of-two string-literal heuristic.

fn
fn Encoder::encode( self : Encoder, headers : ArrayView[@header.Header]) -> Bytes

Encode a header list into a header block (RFC 7541 §6), using indexed fields where possible and literal-with-incremental-indexing otherwise (mutating the dynamic table to mirror what the peer decoder will build). The output decodes back to the same header list via Decoder.

@qpack

RFC 9204 HTTP/3 header compression: both tables, the field lines, both instruction streams.

suberror
suberror Refused {
  Malformed(String)
  Index(got~ : Int32)
  Exceeded(limit~ : Int32, got~ : Int32)
} derive(Eq, Debug)

Why a field section or an instruction was refused (RFC 9204 §2.2, §4). Each one is a connection error of its own kind in §6, because after it the two ends no longer agree about the dynamic table and nothing further can be read.

let
let static_table : Array[@header.Header] =

The static table as fields, in the index order Appendix A gives them. Unlike HPACK's, QPACK's static table is indexed from zero (RFC 9204 §3.1).

fn
fn entry(index : Int32) -> @header.Header?

The entry at index, or None when the index names nothing.

fn
fn find(name : Bytes, value : Bytes) -> Int32?

The index of the first entry whose name and value both match, for an indexed field line; None if no entry matches exactly.

fn
fn find_name(name : Bytes) -> Int32?

The index of the first entry whose name matches (regardless of value), for a literal field line with a name reference; None if no entry has the name.

fn
fn max_entries(capacity : Int32) -> Int32

The number of entries that fit in a table of capacity bytes at the 32-byte minimum entry size — MaxEntries in RFC 9204 §4.5.1.1.

fn
fn prefix( req_insert_count : Int32, base : Int32, max_entries : Int32) -> Bytes

Encode a field-section prefix (RFC 9204 §4.5.1): the Required Insert Count transmitted modulo 2*max_entries, then the Base as a sign bit (0 when base >= req_insert_count) plus the delta.

fn
fn insert_count( encoded : Int32, max_entries : Int32, total_inserts : Int32) -> Int32 raise Refused

Reconstruct the absolute Required Insert Count from its encoded form, max_entries, and the number of inserts the decoder has processed (RFC 9204 §4.5.1.1).

fn
fn read_prefix( input : BytesView, max_entries : Int32, total_inserts : Int32) -> (Int32, Int32, Int32)? raise Refused

Decode a field-section prefix (RFC 9204 §4.5.1), given max_entries and the decoder's total inserts so far. Returns (req_insert_count, base, bytes-consumed), or None on a partial read.

struct
struct Table {
  entries : Array[(Bytes, Bytes)]
  mut used : Int32
  mut capacity : Int32
  mut insert_count : Int32
  mut dropped : Int32
}

A QPACK dynamic table: the live entries, the bytes they occupy, the capacity, the count of entries ever inserted (the next absolute index), and how many have been evicted.

fn
fn Table::new(capacity : Int32) -> Table

A fresh, empty dynamic table with the given capacity in bytes.

fn
fn cost(name : Bytes, value : Bytes) -> Int32

The size an entry occupies in the table: its name and value octets plus 32 (RFC 9204 §3.2.1).

fn
fn Table::resize(self : Table, capacity : Int32) -> Unit

Set the table capacity, evicting whatever no longer fits (RFC 9204 §3.2.3).

fn
fn Table::insert(self : Table, name : Bytes, value : Bytes) -> Bool

Insert an entry, evicting as needed. Returns false (adding nothing) when the entry cannot fit even in an empty table (RFC 9204 §3.2.2).

fn
fn Table::get(self : Table, abs_index : Int32) -> (Bytes, Bytes)?

The entry at absolute index abs_index, or None if it has been evicted or never existed (RFC 9204 §3.2.4).

fn
fn Table::relative(self : Table, rel : Int32) -> (Bytes, Bytes)?

The entry at encoder-stream relative index rel (0 = most recently inserted, RFC 9204 §3.2.5).

fn
fn Table::apply(self : Table, inst : Change) -> Bool raise Refused

Apply an encoder-stream instruction to the table (RFC 9204 §4.3): set the capacity, or insert an entry whose name comes from the static table, a dynamic entry (by relative index), or a literal. Returns whether the resulting insert fit (always true for a capacity change). Raises if a referenced name index is out of range.

fn
fn string( data : Bytes, prefix : Int32, pattern : Int32, huffman : Bool) -> Bytes

Encode a QPACK string literal: the H bit at position prefix (set when the data is Huffman-coded), pattern in the bits above it, the octet length as a prefix-prefix integer, then the (raw or Huffman) octets.

fn
fn read_string( input : BytesView, prefix : Int32) -> (Bytes, Int32)? raise Refused

Decode a QPACK string literal whose length uses a prefix-prefix integer and whose H bit sits at position prefix. Returns (octets, bytes-consumed), or None when the string has not fully arrived.

fn
fn encode(headers : Array[@header.Header], huffman~ : Bool = true) -> Bytes

Encode a header list as a QPACK field section over the static table only: a zero-insert-count / zero-base prefix, then one field line per header — an indexed static entry for an exact match, a static name reference plus a literal value when the name is known, or a fully literal name and value otherwise. huffman chooses whether literal strings are Huffman-coded.

fn
fn decode(input : Bytes) -> Array[@header.Header] raise Refused

Decode a QPACK field section (encoded over the static table) back into its header list. The prefix's insert count and base are read and required to be zero (this decoder resolves static references only); a field line that references the dynamic table is reported as unsupported.

fn
fn decode_with( input : Bytes, table : Table, max_entries : Int32) -> Array[@header.Header] raise Refused

Decode a QPACK field section that may reference the dynamic table, given max_entries (from the negotiated maximum table capacity). Reads the section prefix, then every field line — static or dynamic, indexed or literal, pre- or post-base — into the header list. Raises if a reference is out of range or the section is malformed.

fn
fn encode_with( headers : Array[@header.Header], table : Table, max_entries : Int32, huffman~ : Bool = true) -> (Bytes, Bytes)

Encode headers with dynamic indexing over table (the encoder's view, mutated by the inserts). Returns the encoder-stream instruction block and the field section. max_entries comes from the negotiated maximum table capacity.

enum
enum Change {
  /// Set Dynamic Table Capacity (§4.3.1): `001` + capacity (5-bit prefix).
  SetCapacity(Int32)
  /// Insert With Name Reference (§4.3.2): `1` + T (static/dynamic) + name index (6-bit
  /// prefix), then the value string.
  Insert(is_static~ : Bool, index~ : Int32, value~ : Bytes)
  /// Insert With Literal Name (§4.3.3): `01` + H + name length (5-bit prefix) + name,
  /// then the value string.
  InsertLiteral(name~ : Bytes, value~ : Bytes)
  /// Duplicate (§4.3.4): `000` + index (5-bit prefix).
  Duplicate(index~ : Int32)
} derive(Eq, Debug)

An encoder-stream instruction (RFC 9204 §4.3).

enum
enum Ack {
  /// Section Acknowledgement (§4.4.1): `1` + stream id (7-bit prefix).
  Section(stream_id~ : Int32)
  /// Stream Cancellation (§4.4.2): `01` + stream id (6-bit prefix).
  Cancel(stream_id~ : Int32)
  /// Insert Count Increment (§4.4.3): `00` + increment (6-bit prefix).
  Increment(increment~ : Int32)
} derive(Eq, Debug)

A decoder-stream instruction (RFC 9204 §4.4).

fn
fn Change::encode(inst : Change, huffman~ : Bool = true) -> Bytes

Encode an encoder-stream instruction (RFC 9204 §4.3). huffman chooses whether the literal strings of the insert instructions are Huffman-coded.

fn
fn Change::decode(input : BytesView) -> (Change, Int32)? raise Refused

Decode one encoder-stream instruction from the front of input (RFC 9204 §4.3), dispatching on the pattern in the first byte's high bits. Returns (instruction, bytes-consumed), or None when the instruction has not fully arrived.

fn
fn Ack::encode(inst : Ack) -> Bytes

Encode a decoder-stream instruction (RFC 9204 §4.4).

fn
fn Ack::decode(input : BytesView) -> (Ack, Int32)? raise Refused

Decode one decoder-stream instruction from the front of input (RFC 9204 §4.4). Returns (instruction, bytes-consumed), or None on a partial read.

@http1

RFC 9112 messages: request and status lines, field lines, body framing, the chunked coding; and Conn, a whole connection on h11's state machine.

suberror
suberror Refused {
  Partial
  Malformed(String)
  Exceeded(limit~ : Int32, got~ : Int32)
  Smuggled(String)
  Order(String)
} derive(Eq, Debug)

Why a message was refused. Partial is not a fault: it is the reader saying the bytes have not all arrived, which is the answer a caller feeds more and asks again. Order is this side's mistake, an event sent where the exchange does not allow it. Everything else is a peer's message that will not become valid by waiting.

fn
fn Refused::status(self : Refused) -> Int32

The status a refusal deserves, for a server that answers rather than hangs up. A message too large is 431 when it is the head (RFC 6585 §5) and 413 when it is the body; this is about the head, which is what this package bounds. A framing fault is 400.

enum
enum Body {
  Empty
  Sized(Int32)
  Chunked
  // Until the connection closes, which only a response may use (§6.3).
  Closed
} derive(Eq, Debug)

How a message's body is delimited (RFC 9112 §6). The three ways are not interchangeable and picking the wrong one is how a request gets smuggled past a proxy, so this is read from the headers once and carried rather than re-derived.

struct
struct Limits {
  head : Int32
  fields : Int32
} derive(Eq, Debug)

The limits a reader holds a peer to. The preset is [limits]. Build another with [Limits::new], or update one in place with { ..limits, head: 8192 }.

let
let limits : Limits =

Sixteen kibibytes of head and a hundred fields. 16 KiB is what h11 caps an incomplete event at and what uvicorn exposes as --h11-max-incomplete-event-size; nginx's large_client_header_buffers and Go's MaxHeaderBytes land in the same place. A hundred fields is Go's http2.Server default and more than any real request carries.

fn
fn Limits::new( head~ : Int32 = limits.head, fields~ : Int32 = limits.fields) -> Limits

Limits by name, every knob with the preset's value.

struct
struct Request {
  verb : String
  target : String
  version : String
  fields : Array[@header.Header]
  body : Body
  // `Expect: 100-continue` — the client is waiting to be told to send the body.
  expects : Bool
  keep_alive : Bool
} derive(Eq, Debug)

A request's head: the request line and the fields under it. Field names are lowercased, because HTTP/1.1 says they are case-insensitive and every consumer would otherwise lowercase them again. Values are octets, not text: a field value may carry anything, and gRPC's -bin metadata does.

struct
struct Response {
  version : String
  status : Int32
  reason : String
  fields : Array[@header.Header]
  body : Body
  keep_alive : Bool
} derive(Eq, Debug)

A response's head: the status line and the fields under it.

fn
fn Request::field(self : Request, name : String) -> Bytes?

The value of the first field named name, or None.

fn
fn Response::field(self : Response, name : String) -> Bytes?

The same, for a response.

enum
enum Role {
  Server
  Client
} derive(Eq, Debug)

Which end of a connection a [Conn] is.

enum
enum State {
  Idle
  SendResponse
  SendBody
  Done
  MustClose
  Closed
  MightSwitch
  Switched
  Error
} derive(Eq, Debug)

Where one side of a connection is in its half of an exchange. These are h11's states (https://h11.readthedocs.io/en/latest/api.html), one per side, read with [Conn::ours] and [Conn::theirs]. A server that has read a request is SendResponse until it answers. A side whose exchange said Connection: close ends in MustClose rather than Done. A client that asked to upgrade waits in MightSwitch until the server says yes, which is Switched for both, or no, which puts it back to Done.

enum
enum Event {
  Request(Request)
  Response(Response)
  // A 1xx: `100 Continue`, or the `101` that switches protocols.
  Informational(Response)
  Data(Bytes)
  // The end of a message, with its trailer fields.
  End(Array[@header.Header])
  NeedData
  Paused
  // Read, the peer closed its side; sent, this side is closing its own.
  Closed
} derive(Eq, Debug)

What a connection reads from its peer, or is given to write. A message is always a head, any number of Data, then End, even when the body is empty, so a consumer handles one sequence. NeedData and Paused are only ever read: the first asks for more octets, the second says the next message waits for [Conn::start_next] or, after a switch, belongs to another protocol.

fn
fn Conn::new(role : Role, limits~ : Limits = limits) -> Conn

A connection at its start, both sides Idle. limits bounds what the peer may send before a message is complete: its head, a chunk-size line, a trailer section. Bodies are handed over as they arrive and are not bounded here.

fn
fn Conn::ours(self : Conn) -> State

This side's state.

fn
fn Conn::theirs(self : Conn) -> State

The peer's state.

fn
fn Conn::receive(self : Conn, data : BytesView) -> Unit raise Refused

Octets read from the peer. Empty means the peer closed its side. Raises [Refused::Order] for octets after that, which no transport delivers.

fn
fn Conn::next(self : Conn) -> Event raise Refused

The next event from the peer. Bodies come as Data in the pieces they arrived in, whether sized, chunked or running to the close. NeedData asks for [Conn::receive]; while it is returned, the octets held past limits.head raise [Refused::Exceeded]. A head that does not parse raises what [request] or [response] would, an event out of order raises Malformed, and either way the peer's state becomes Error and the connection will not be reused. A server may still answer it: [Refused::status] gives the status.

fn
fn Conn::send(self : Conn, event : Event) -> Bytes raise Refused

The octets that write event to the peer. A response's framing is decided here rather than trusted. A Content-Length field is used as given. Without one the body is chunked to an HTTP/1.1 peer and runs to the close otherwise, with the fields rewritten to say so. A response to HEAD keeps the fields a GET would get and drops the body. A Connection: close is added whenever the connection will not be reused, and Connection: keep-alive for an HTTP/1.0 peer when it will. A request is written as given, framed by its own fields. Raises [Refused::Order] for an event this side may not send now: data before a head, more body than its Content-Length, a body that ends short of it. This side's state then becomes Error.

fn
fn Conn::start_next(self : Conn) -> Unit raise Refused

Begin the next exchange on a kept-alive connection. Both sides must be Done; a side in MustClose means the connection is to be closed instead. A pipelined request already received is read by the next [Conn::next].

fn
fn Conn::trailing(self : Conn) -> Bytes

The octets received but not read as HTTP. After a switch these belong to the new protocol: a WebSocket frame that came in the same segment as the upgrade request, for instance.

fn
fn request( data : BytesView, limits~ : Limits = limits) -> (Request, Int32) raise Refused

The request at the front of data, and how many octets it took. Raises [Refused::Partial] while the head has not all arrived, which is the answer a caller reads more on. This reads the head only: the body is framed by [Request::body] and is the caller's to collect, because a body may be larger than anything worth holding in one buffer.

fn
fn response( data : BytesView, verb~ : String = "GET", limits~ : Limits = limits) -> (Response, Int32) raise Refused

The response at the front of data, and how many octets it took. verb is the one the request used, because it decides the framing: a response to HEAD has no body however its fields read (RFC 9112 §6.3).

fn
fn Request::encode(self : Request) -> Bytes

A request head as octets. The fields are written as given; this does not add a Host, a Content-Length or anything else, because a caller that wanted those would have put them in and one that did not would be surprised to find them.

fn
fn Response::encode(self : Response) -> Bytes

A response head as octets.

fn
fn Request::of( verb : String, target : String, fields~ : Array[@header.Header] = [], version~ : String = "HTTP/1.1", body~ : Body = Empty, host~ : String = "", keep_alive~ : Bool = true) -> Request

A request by name, with the framing worked out from what it is given. body decides what is written: a sized body gets a Content-Length, a chunked one a Transfer-Encoding, and neither gets neither. That is the one thing a hand-built request gets wrong, so it is not left to the caller.

fn
fn Response::of( status : Int32, fields~ : Array[@header.Header] = [], reason~ : String = "", version~ : String = "HTTP/1.1", body~ : Body = Empty, keep_alive~ : Bool = true) -> Response

A response by name, with the framing worked out the same way.

fn
fn phrase(status : Int32) -> String

The reason phrase a status is usually written with (RFC 9110 §15). A client must not act on it (§4), so this is for the human reading a capture.

let
let continue_ : Bytes = b"HTTP/1.1 100 Continue\r\n\r\n"

The interim 100 Continue a server sends when a request said it was waiting (RFC 9110 §10.1.1).

fn
fn chunk(data : BytesView) -> Bytes

One chunk: its size in hexadecimal, the octets, and a break (RFC 9112 §7.1).

fn
fn last_chunk(trailers~ : Array[@header.Header] = []) -> Bytes

The zero-size chunk that ends a body, with optional trailer fields after it.

enum
enum Chunk {
  // Octets of the body, and how many octets of `data` they took.
  Piece(Bytes, Int32)
  // The body ended; its trailer fields, and how many octets that took.
  Done(Array[@header.Header], Int32)
} derive(Eq)

What a chunked body reader found at the front of data.

fn
fn chunked(data : BytesView) -> Chunk raise Refused

The chunk at the front of data. Raises [Refused::Partial] while it has not all arrived. A size line longer than sixteen hexadecimal digits is refused rather than accumulated: a size that needs more than sixty-four bits is not a size.

@http2

RFC 9113 framing: the nine-octet header, all ten frame types, the connection preface; and Conn, a whole connection after python-hyper h2.

let
let preface : Bytes = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

The HTTP/2 client connection preface (RFC 9113 §3.5): the fixed, case-sensitive 24-octet sequence PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n a client sends before its first frame, which must be a SETTINGS frame. Its bytes deliberately form a malformed HTTP/1.1 request so an HTTP/1.1-only server rejects it cleanly.

fn
fn has_preface(data : Bytes) -> Bool

Whether data begins with the exact 24-octet HTTP/2 connection preface.

let
let frame_data : Int32 = 0x0

HTTP/2 frame type codes (RFC 9113 §6).

let
let frame_headers : Int32 = 0x1

HEADERS: opens a stream and carries its HPACK header block.

let
let frame_priority : Int32 = 0x2

PRIORITY: the deprecated stream-dependency hint; accepted and ignored.

let
let frame_rst_stream : Int32 = 0x3

RST_STREAM: abort one stream without touching the connection.

let
let frame_settings : Int32 = 0x4

SETTINGS: the connection parameters each peer announces, and their ack.

let
let frame_push_promise : Int32 = 0x5

PUSH_PROMISE: server push, which gRPC never uses.

let
let frame_ping : Int32 = 0x6

PING: the round-trip probe keepalive is built on.

let
let frame_goaway : Int32 = 0x7

GOAWAY: stop opening streams, and here is the last id I accepted.

let
let frame_window_update : Int32 = 0x8

WINDOW_UPDATE: hand the peer more flow-control credit.

let
let frame_continuation : Int32 = 0x9

CONTINUATION: the rest of a header block too large for one frame.

let
let flag_end_stream : Int32 = 0x1

HTTP/2 frame flags (RFC 9113 §6). Flags are type-specific; the same bit carries different meaning per frame type, hence the shared numeric values.

let
let flag_ack : Int32 = 0x1

ACK on SETTINGS and PING shares bit 0x1 with END_STREAM.

let
let flag_end_headers : Int32 = 0x4

END_HEADERS: no CONTINUATION follows this header block.

let
let flag_padded : Int32 = 0x8

PADDED: a length byte and that many padding bytes wrap the payload.

let
let flag_priority : Int32 = 0x20

PRIORITY: this HEADERS frame carries a stream-dependency block.

let
let settings_header_table_size : Int32 = 0x1

SETTINGS parameter identifiers (RFC 9113 §6.5.2).

let
let settings_enable_push : Int32 = 0x2

SETTINGS_ENABLE_PUSH: whether the peer may send PUSH_PROMISE.

let
let settings_max_concurrent_streams : Int32 = 0x3

SETTINGS_MAX_CONCURRENT_STREAMS: how many streams may be open at once.

let
let settings_initial_window_size : Int32 = 0x4

SETTINGS_INITIAL_WINDOW_SIZE: the flow-control credit a new stream starts with.

let
let settings_max_frame_size : Int32 = 0x5

SETTINGS_MAX_FRAME_SIZE: the largest payload the peer will accept in one frame.

let
let settings_max_header_list_size : Int32 = 0x6

SETTINGS_MAX_HEADER_LIST_SIZE: the advisory ceiling on a decoded header list.

let
let settings_enable_connect_protocol : Int32 = 0x8

SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC 8441 §3): the extended CONNECT that carries WebSockets over HTTP/2.

let
let error_no_error : Int32 = 0x0

HTTP/2 error codes (RFC 9113 §7), carried by RST_STREAM and GOAWAY.

let
let error_protocol_error : Int32 = 0x1

PROTOCOL_ERROR: the peer broke the protocol in a way not covered below.

let
let error_internal_error : Int32 = 0x2

INTERNAL_ERROR: the endpoint failed for reasons of its own.

let
let error_flow_control_error : Int32 = 0x3

FLOW_CONTROL_ERROR: the peer sent more than its credit allowed.

let
let error_settings_timeout : Int32 = 0x4

SETTINGS_TIMEOUT: a SETTINGS frame went unacknowledged too long.

let
let error_stream_closed : Int32 = 0x5

STREAM_CLOSED: a frame arrived for a stream that was already finished.

let
let error_frame_size_error : Int32 = 0x6

FRAME_SIZE_ERROR: the frame's length is invalid for its type.

let
let error_refused_stream : Int32 = 0x7

REFUSED_STREAM: the stream was declined before any processing, so it is safe to retry.

let
let error_cancel : Int32 = 0x8

CANCEL: the stream is no longer wanted.

let
let error_compression_error : Int32 = 0x9

COMPRESSION_ERROR: the HPACK context is corrupt, which kills the connection.

let
let error_connect_error : Int32 = 0xA

CONNECT_ERROR: the TCP connection behind a CONNECT stream failed.

let
let error_enhance_your_calm : Int32 = 0xB

ENHANCE_YOUR_CALM: the peer is generating too much load.

let
let error_inadequate_security : Int32 = 0xC

INADEQUATE_SECURITY: the transport does not meet the minimum this endpoint requires.

let
let error_http_1_1_required : Int32 = 0xD

HTTP_1_1_REQUIRED: the request cannot be served over HTTP/2.

struct
struct Priority {
  exclusive : Bool
  stream_dependency : Int32
  weight : Int32
} derive(Eq)

The stream-priority block shared by PRIORITY frames and the optional priority section of HEADERS (RFC 9113 §5.3.2). weight is the raw wire byte (0..255); the effective priority weight is weight + 1 (§6.3).

suberror
suberror Refused {
  Incomplete
  FrameSizeError(String)
  ProtocolError(String)
  Connection(code~ : Int32, String)
  Order(String)
} derive(Eq)

Why a frame or a connection was refused. Incomplete is the normal "need more bytes" signal a streaming reader catches to wait for the rest, and FrameSizeError and ProtocolError are frames that do not decode. A [Conn] raises only the last two kinds: Connection when the peer broke the connection, with the GOAWAY that says so already queued, and Order when this side sent something the protocol does not allow now.

enum
enum Frame {
  Data(stream_id~ : Int32, data~ : Bytes, end_stream~ : Bool, padding~ : Int32)
  Headers(
    stream_id~ : Int32,
    fragment~ : Bytes,
    end_stream~ : Bool,
    end_headers~ : Bool,
    priority~ : Priority?,
    padding~ : Int32
  )
  Priority(stream_id~ : Int32, priority~ : Priority)
  RstStream(stream_id~ : Int32, error_code~ : Int32)
  Settings(params~ : Array[(Int32, Int32)], ack~ : Bool)
  PushPromise(
    stream_id~ : Int32,
    promised_id~ : Int32,
    fragment~ : Bytes,
    end_headers~ : Bool,
    padding~ : Int32
  )
  Ping(payload~ : Bytes, ack~ : Bool)
  GoAway(last_stream_id~ : Int32, error_code~ : Int32, debug~ : Bytes)
  WindowUpdate(stream_id~ : Int32, increment~ : Int32)
  Continuation(stream_id~ : Int32, fragment~ : Bytes, end_headers~ : Bool)
  Unknown(ftype~ : Int32, flags~ : Int32, stream_id~ : Int32, payload~ : Bytes)
} derive(Eq)

A decoded HTTP/2 frame (RFC 9113 §6). Each variant carries the semantic payload with padding already stripped; padding is the number of padding bytes to (re)emit. Unknown preserves extension/unrecognised frames verbatim so a reader can forward or ignore them (RFC 9113 §4.1).

struct
struct FrameHeader {
  length : Int32
  ftype : Int32
  flags : Int32
  stream_id : Int32
} derive(Eq)

The fixed 9-octet frame header (RFC 9113 §4.1): a 24-bit payload length, an 8-bit type, 8-bit flags, a reserved bit, and a 31-bit stream identifier.

fn
fn FrameHeader::encode(self : FrameHeader) -> Bytes

Encode a FrameHeader to its 9 wire octets.

fn
fn FrameHeader::decode( data : Bytes, offset~ : Int32 = 0) -> FrameHeader raise Refused

Decode the 9-octet frame header at offset. Raises Incomplete when fewer than 9 octets are available. The reserved bit is masked off the stream id.

fn
fn Frame::code(self : Frame) -> Int32

The numeric frame-type code of this frame (RFC 9113 §6).

fn
fn Frame::encode(self : Frame) -> Bytes

Encode this frame to its complete wire representation (9-octet header + payload), the exact inverse of decode_frame.

fn
fn decode( data : Bytes, offset~ : Int32 = 0) -> (Frame, Int32) raise Refused

Decode exactly one frame at offset, returning (frame, bytes_consumed) where bytes_consumed is 9 + payload_length. Raises Incomplete when the buffer does not yet hold the whole frame, or a protocol error when the payload is malformed for its type. The inverse of Frame::encode.

enum
enum Role {
  Server
  Client
} derive(Eq, Debug)

Which end of a connection a [Conn] is.

enum
enum Event {
  Request(stream~ : Int32, fields~ : Array[@header.Header])
  Response(stream~ : Int32, fields~ : Array[@header.Header])
  // A 1xx response, of which a stream may have several before its final one.
  Informational(stream~ : Int32, fields~ : Array[@header.Header])
  Trailers(stream~ : Int32, fields~ : Array[@header.Header])
  Data(stream~ : Int32, data~ : Bytes)
  End(stream~ : Int32)
  Reset(stream~ : Int32, code~ : Int32, remote~ : Bool)
  // The server promised `promised`, a response to the request in `fields`, on
  // the client's stream `stream`.
  Pushed(stream~ : Int32, promised~ : Int32, fields~ : Array[@header.Header])
  // Stream 0 is the connection.
  WindowUpdated(stream~ : Int32, delta~ : Int32)
  // The peer's settings, now in force.
  Settings(Settings)
  // This side's settings, now that the peer has acknowledged them.
  SettingsAcked(Settings)
  Ping(Bytes)
  PingAcked(Bytes)
  GoAway(code~ : Int32, last~ : Int32, debug~ : Bytes)
  NeedData
  // The peer closed the transport.
  Closed
} derive(Eq, Debug)

What a connection reads from its peer. As in http1, a message is a head, any number of Data, then End; trailers come as Trailers before the End. Reset with remote=false is a stream this side refused, with the RST_STREAM already queued. Settings and Ping have already been acknowledged when they are read.

fn
fn Conn::new(role : Role, settings~ : Settings = settings) -> Conn

A connection at its start, with its opening frames queued: the client's preface and SETTINGS, or the server's SETTINGS (§3.4). settings take effect when the peer acknowledges them; until then both ends go by [initial]. Settings outside what §6.5.2 allows abort, since no peer could accept them.

fn
fn Conn::receive(self : Conn, data : BytesView) -> Unit raise Refused

Octets read from the peer. Empty means the peer closed the transport.

fn
fn Conn::next(self : Conn) -> Event raise Refused

The next event from the peer. A connection error raises Connection with the GOAWAY already queued in [Conn::take], after which the connection is finished. A stream error does not raise: the RST_STREAM is queued and the stream ends with Reset.

fn
fn Conn::take(self : Conn) -> Bytes

The octets queued for the peer since the last call.

fn
fn Conn::settings(self : Conn) -> Settings

This side's settings in force.

fn
fn Conn::peer_settings(self : Conn) -> Settings

The peer's settings in force.

fn
fn Conn::next_stream(self : Conn) -> Int32

The id the next stream this side opens, or the next push it promises, will have.

fn
fn Conn::window(self : Conn, stream : Int32) -> Int32

How much this side may send on stream now, the smaller of its own window and the connection's; stream 0 is the connection alone.

fn
fn Conn::headers( self : Conn, stream : Int32, fields : Array[@header.Header], end~ : Bool = false) -> Unit raise Refused

Send a head on stream: a client's request on a stream it opens with this, a server's informational or final response, or either side's trailers. Which one it is follows from the stream: a new id from a client is a request, the first head a server sends is its response (a 1xx leaves room for another), and a head after the final one is the trailers, which must end the stream. The fields are checked against RFC 9113 §8.2 and §8.3 as a peer would check them, and a block longer than the peer's frame size is carried on in CONTINUATION frames. Raises [Refused::Order] for a head the protocol does not allow here.

fn
fn Conn::data( self : Conn, stream : Int32, data : BytesView, end~ : Bool = false) -> Unit raise Refused

Send data on stream, in frames no longer than the peer allows. It must fit in [Conn::window]; a caller with more waits for WindowUpdated. Empty data with end sends the END_STREAM alone.

fn
fn Conn::end(self : Conn, stream : Int32) -> Unit raise Refused

End stream from this side with an empty DATA frame.

fn
fn Conn::reset( self : Conn, stream : Int32, code~ : Int32 = error_no_error) -> Unit raise Refused

Reset stream (§6.4). NO_ERROR is h2's default too: a reset is as often a caller that no longer wants the stream as a fault.

fn
fn Conn::push( self : Conn, stream : Int32, fields : Array[@header.Header]) -> Int32 raise Refused

Promise the response to the request in fields on the client's stream, and the id of the new stream the response goes on (§8.4). The response is then sent with [Conn::headers] and [Conn::data] on the returned id.

fn
fn Conn::ping(self : Conn, payload : Bytes) -> Unit raise Refused

Send a PING with eight octets of payload; the answer comes as PingAcked.

fn
fn Conn::goaway( self : Conn, code~ : Int32 = error_no_error, debug~ : Bytes = b"") -> Unit raise Refused

Tell the peer this side will process no stream past the last one it opened (§6.8). Streams already open carry on; new ones from the peer are ignored.

fn
fn Conn::update(self : Conn, settings : Settings) -> Unit raise Refused

Move this side to settings. They take effect when the peer acknowledges them, reported as SettingsAcked.

fn
fn Conn::acknowledge( self : Conn, stream : Int32, size : Int32) -> Unit raise Refused

Hand back size octets of stream's data, now processed, so the peer may send more (§6.9). WINDOW_UPDATE goes out when half a window has come back, as h2 does; a caller that never acknowledges stalls the peer.

fn
fn Conn::widen( self : Conn, stream : Int32, by : Int32) -> Unit raise Refused

Widen the receive window of stream, or of the connection for stream 0, by by octets at once, beyond what acknowledgement would.

struct
struct Settings {
  header_table_size : Int32
  enable_push : Bool
  max_concurrent_streams : Int32?
  initial_window_size : Int32
  max_frame_size : Int32
  max_header_list_size : Int32?
  enable_connect_protocol : Bool
} derive(Eq, Debug)

An endpoint's settings (RFC 9113 §6.5.2, RFC 8441 §3). None is the RFC's "unlimited". The preset is [settings]; [initial] is what both ends assume before any SETTINGS frame, and so what the peer's settings start from. Build others with [Settings::new] or { ..settings, max_frame_size: n }.

let
let initial : Settings =

The value RFC 9113 §6.5.2 gives every setting before an endpoint has said otherwise.

let
let settings : Settings =

What a connection advertises unless told otherwise: the RFC's values, with a hundred concurrent streams and 64 KiB of header list. Those two are python-hyper h2's own defaults, and a hundred streams is also where nghttp2 and gRPC settle. Left unlimited, every stream a peer opens and never finishes holds memory, and a few octets of HPACK can name one large table entry thousands of times.

fn
fn Settings::new( header_table_size~ : Int32 = settings.header_table_size, enable_push~ : Bool = settings.enable_push, max_concurrent_streams~ : Int32? = settings.max_concurrent_streams, initial_window_size~ : Int32 = settings.initial_window_size, max_frame_size~ : Int32 = settings.max_frame_size, max_header_list_size~ : Int32? = settings.max_header_list_size, enable_connect_protocol~ : Bool = settings.enable_connect_protocol) -> Settings

Settings by name, every one with the preset's value.

@http3

RFC 9114 frames, their placement rules, settings, stream types and connection.

suberror
suberror Refused {
  Malformed(String)
} derive(Eq, Debug)

Why a frame, a stream or a connection was refused. RFC 9114 §8 gives each case its own connection error; they are one type here because the answer to every one of them is the same — close the connection — and the message says which it was.

enum
enum Frame {
  Data(Bytes)
  Headers(Bytes)
  CancelPush(UInt64)
  Settings(Array[(UInt64, UInt64)])
  PushPromise(UInt64, Bytes)
  GoAway(UInt64)
  MaxPushId(UInt64)
  Reserved(UInt64, Bytes)
  ReservedHttp2(UInt64)
} derive(Eq, Debug)

A decoded HTTP/3 frame. Data/Headers/PushPromise carry their opaque payloads; Settings carries the decoded (identifier, value) pairs. Reserved is any unknown or grease frame type (RFC 9114 §7.2.8 / §9), which a receiver ignores; ReservedHttp2 is one of the frame types reused from HTTP/2 (0x02, 0x06, 0x08, 0x09), which RFC 9114 §7.2 requires be treated as a connection error.

fn
fn Frame::encode(frame : Frame) -> Bytes

Encode one HTTP/3 frame: its type, its payload length, then the payload.

fn
fn settings(pairs : Array[(UInt64, UInt64)]) -> Bytes

Encode a SETTINGS payload: each pair as its identifier varint then its value varint (RFC 9114 §7.2.4).

fn
fn decode(input : BytesView) -> (Frame, Int32)? raise Refused

Decode one HTTP/3 frame from the front of input. Returns Some((frame, consumed)) on a complete frame, None when more bytes are needed (a partial frame — the type, the length, or the payload has not fully arrived), and raises on a malformed frame (a truncated SETTINGS pair or a required varint field that is absent).

fn
fn read_settings(payload : Bytes) -> Array[(UInt64, UInt64)] raise Refused

Decode a SETTINGS payload into its (identifier, value) pairs. A trailing partial pair (an identifier with no value, or a truncated varint) is a frame error.

fn
fn decode_all(input : Bytes) -> (Array[Frame], Int32) raise Refused

Decode every complete frame at the front of input, returning the frames and the number of octets consumed (the tail, a partial frame, is left for the next read). A stream reader feeds accumulated bytes and keeps the unconsumed remainder.

enum
enum On {
  Control
  Request
  Push
} derive(Eq, Debug)

The stream a frame was received on, for the purpose of frame-placement rules (RFC 9114 §6.2).

fn
fn allowed(frame : Frame, ctx : On) -> Bool

Whether frame is permitted on a stream of kind ctx (RFC 9114 §7.2).

fn
fn check(frame : Frame, ctx : On) -> Unit raise Refused

Raise Refused (H3_FRAME_UNEXPECTED) when frame is not permitted on ctx.

let
let qpack_capacity : UInt64 = 0x01

SETTINGS_QPACK_MAX_TABLE_CAPACITY (RFC 9204 §5).

let
let max_field_section : UInt64 = 0x06

SETTINGS_MAX_FIELD_SECTION_SIZE (RFC 9114 §7.2.4.1).

let
let blocked_streams : UInt64 = 0x07

SETTINGS_QPACK_BLOCKED_STREAMS (RFC 9204 §5).

struct
struct Settings {
  qpack_max_table_capacity : UInt64
  max_field_section_size : UInt64
  qpack_blocked_streams : UInt64
} derive(Eq, Debug)

An endpoint's HTTP/3 settings.

fn
fn Settings::new( qpack_max_table_capacity : UInt64, max_field_section_size : UInt64, qpack_blocked_streams : UInt64) -> Settings

Settings with the given QPACK table capacity, maximum field section size, and blocked-stream limit.

fn
fn Settings::pairs(self : Settings) -> Array[(UInt64, UInt64)]

The (identifier, value) pairs to carry in a SETTINGS frame.

fn
fn Settings::of_pairs(pairs : Array[(UInt64, UInt64)]) -> Settings

Read settings from received (identifier, value) pairs, taking the value for each known identifier (0 when absent) and ignoring unknown ones (RFC 9114 §7.2.4.1 grease tolerance).

fn
fn read_control(input : BytesView) -> Settings? raise Refused

Decode an endpoint's control stream (RFC 9114 §6.2.1): its type prefix must be the control stream, and its first frame a SETTINGS frame, whose settings are returned. None if the stream is not a control stream or does not open with SETTINGS. Raises on a malformed frame.

enum
enum Stream {
  ControlKind
  PushKind(UInt64)
  EncoderKind
  DecoderKind
  ReservedKind(UInt64)
} derive(Eq, Debug)

An HTTP/3 unidirectional stream's type (RFC 9114 §6.2, RFC 9204 §4.2).

fn
fn Stream::encode(stream : Stream) -> Bytes

Encode a unidirectional stream's opening bytes: the type varint, plus the push id for a push stream (RFC 9114 §6.2.2).

fn
fn Stream::decode(input : BytesView) -> (Stream, Int32)?

Decode a unidirectional stream's opening bytes: the type varint (and a push id for a push stream). Returns the stream kind and bytes consumed, or None on a partial read.

fn
fn control(settings : Array[(UInt64, UInt64)]) -> Bytes

Encode a control stream (RFC 9114 §6.2.1): the control-stream type, then a SETTINGS frame with settings, which a control stream must send first.

fn
fn message(headers : Array[@header.Header], body : Bytes) -> Bytes

Encode a message body as an HTTP/3 request/response stream: a HEADERS frame with the QPACK-encoded headers (static table only), then a DATA frame when body is non-empty.

fn
fn read_message( bytes : Bytes) -> (Array[@header.Header], Bytes) raise Refused

Decode an HTTP/3 request/response stream back into its header list and body: the first HEADERS frame gives the headers, the DATA frames concatenate into the body, and a second HEADERS frame (trailers) and unknown frames are ignored (RFC 9114 §4.1). Raises if there is no HEADERS frame or a frame is malformed.

fn
fn request( meth : String, scheme : String, authority : String, path : String, headers : Array[@header.Header], body : Bytes) -> Bytes

Encode an HTTP/3 request: the :method, :scheme, :authority, and :path pseudo- headers (RFC 9114 §4.3.1), then headers, then the body.

fn
fn response( status : String, headers : Array[@header.Header], body : Bytes) -> Bytes

Encode an HTTP/3 response: the :status pseudo-header (RFC 9114 §4.3.2), then headers, then the body.

enum
enum Role {
  Client
  Server
} derive(Eq, Debug)

Which side of the connection an endpoint is (RFC 9114 §3.1): a server must not receive a push stream, so the role decides whether one is a protocol violation.

enum
enum Event {
  ControlEstablished(Settings)
  QpackEncoder
  QpackDecoder
  Push(UInt64)
  Reserved(UInt64)
} derive(Eq, Debug)

The result of accepting a peer's unidirectional stream: the control stream carries the peer's SETTINGS, the QPACK streams carry table instructions, a push stream carries its id, and an unrecognized type is reserved/greased.

struct
struct Conn {
  role : Role
  local_settings : Settings
  mut peer_settings : Settings?
  mut control_seen : Bool
}

One endpoint of an HTTP/3 connection: its role, the settings it advertises, and — once the peer opens its control stream — the peer's settings.

fn
fn Conn::new(role : Role, local_settings : Settings) -> Conn

A connection that will advertise local_settings.

fn
fn Conn::open_control_stream(self : Conn) -> Bytes

The bytes to write when opening our control stream: the control-stream type then our SETTINGS frame, which a control stream must send first (RFC 9114 §6.2.1).

fn
fn Conn::accept_uni_stream(self : Conn, bytes : Bytes) -> Event raise

Accept a peer's unidirectional stream from its opening bytes: classify it and, for the control stream, decode and keep the peer's SETTINGS. A second control stream, a control stream not opening with SETTINGS, and a server receiving a push stream are all connection errors (RFC 9114 §6.2).

fn
fn Conn::settings_established(self : Conn) -> Bool

Whether the peer's control-stream SETTINGS have been received (connection setup complete).

fn
fn Conn::peer_settings(self : Conn) -> Settings?

The peer's advertised settings, once its control stream has been accepted.

struct
struct Request {
  meth : String
  scheme : String
  authority : String
  path : String
  headers : Array[@header.Header]
  body : Bytes
}

A decoded HTTP/3 request: the pseudo-headers pulled out, the remaining header fields, and the body.

fn
fn read_request( bytes : Bytes, strict~ : Bool = false) -> Request? raise Refused

Decode an HTTP/3 request stream into a request, separating the pseudo-headers from the regular ones (RFC 9114 §4.3.1). None when the required :method and :path are absent. Raises on a malformed stream. strict also applies §7.2's placement rules, so a frame that belongs on the control stream — a SETTINGS, say — is refused rather than read past. It is off by default because a reader that only wants the request need not care what else arrived.

struct
struct Response {
  status : String
  headers : Array[@header.Header]
  body : Bytes
}

An HTTP/3 response: the status, the header fields, and the body.

fn
fn Response::encode(self : Response) -> Bytes

Encode the response as an HTTP/3 response stream (a HEADERS frame with :status, then DATA).

fn
fn serve( request_bytes : Bytes, handler : (Request) -> Response) -> Bytes raise Refused

Serve one HTTP/3 request stream: decode the request, apply handler, and encode its response stream. A request missing its pseudo-headers is answered with a 400. Raises on a malformed request stream.