moonhttp

Guide · fourteen steps

From octets
to decisions.

One message at a time, in the order it travels: a head, an answer, the fields around it, and then the decisions a cache or a client makes before anything is sent. Every output shown here came from running the code.

20 minutes/MoonBit/no sockets
01 — INSTALL

One module, nineteen packages

Add the module once. A file then imports the package it needs by path, and calls it through the name that path ends in.

shell
moon add moonbitstack/moonhttp
moonbit
import {
  "moonbitstack/moonhttp/http1",      // @http1
  "moonbitstack/moonhttp/conditional" // @conditional
}

Nothing has to be initialised, and no package holds global state. A parser is a function; a connection is a value a caller keeps.

02 — A REQUEST HEAD

A head, and how much of the buffer it used

A request arrives as octets, and the first thing to know is where it ends. request answers both: the head as a record, and the count of octets it took. Everything after that count is the body — or, on a kept-alive connection, the next message.

moonbit
// The octets as they arrived.
let head = b"GET /notes/42 HTTP/1.1\r\nHost: example.com\r\nIf-None-Match: \"v7\"\r\n\r\n"
let (request, used) = @http1.request(head[:])
println("\{request.verb} \{request.target} \{used}")
GET /notes/42 66

Fields are looked up without regard to case and come back as bytes, not as text: a field value is whatever the peer sent, and deciding it is text is a later, fallible step.

moonbit
request.field("if-none-match")  // Some(b'"v7"')
request.field("if-none-match").unwrap()[:]  // b'"v7"', quotes included
03 — THE ANSWER

A status line, in both directions

The same package reads a response and writes one. Response::of takes a status, the fields and a body; encode writes the octets. The reason phrase is the registered one unless a caller says otherwise, and a 304 carries neither a body nor the framing fields a body would need.

moonbit
let not_modified = @http1.Response::of(
  304,
  fields=[@header.Header::of("ETag", "\"v7\"")],
  reason="Not Modified",
)
let wire = not_modified.encode()
HTTP/1.1 304 Not Modified\r\nETag: "v7"\r\n\r\n

Reading one works the other way: @http1.response(bytes[:], verb="GET") takes the verb because a response's body framing depends on the request that asked for it — a HEAD response has the fields of a body and none of the body.

04 — METHODS

Safe is not the same as idempotent

Two properties decide what a client may do with a request it has already sent: safe means the method asks for nothing to change, idempotent means sending it twice is the same as sending it once. Retrying, redirecting and caching all turn on one of them.

MethodSafeIdempotentWhat it is for
GETyesyesthe representation itself
HEADyesyesthe fields, without the body
POSTnonowhatever the target does with it
PUTnoyesreplace the representation
PATCHnonoa delta to the representation
DELETEnoyesremove it
OPTIONSyesyeswhat may be asked of it
TRACEyesyesthe request as it was received
CONNECTnonoa tunnel through the origin
moonbit
let verb = @method.Method::of("PUT"[:])
verb.is_safe()        // false
verb.is_idempotent()  // true — two PUTs, one result
05 — EVENT STREAMS

Frames are written, events are dispatched

An event is a few lines and a blank one. Writing is encode; reading is events, which follows what an EventSource does rather than what the bytes say: a comment dispatches nothing, a frame without data dispatches nothing, and the type defaults to message.

moonbit
let frame = @sse.Event::new(data="{\"seq\":1}", kind="tick", id="1").encode()
let events = @sse.events(frame[:])
id: 1 event: tick data: {"seq":1} 1 event — data {"seq":1}, kind tick

Sending each frame as its own chunk is the caller's job, and it matters: a stream delivered as a single body is not a stream, because a client dispatches an event only when it reads that event's blank line.

06 — COOKIES

One header answers, the other one asks

Set-Cookie travels alone and carries attributes; Cookie travels as one line and carries none. The two sides are different formats, so they are different calls, and a cookie read from a request has no attributes because the request never had them.

moonbit
let cookie = @cookie.Cookie::new(
  "sid",
  "8f2c",
  http_only=true,
  secure=true,
  same_site=Some(@cookie.SameSite::Lax),
).encode()
let token = @cookie.get("a=1; sid=8f2c; b=2"[:], "sid"[:]).unwrap()
println(cookie)
println(token)
sid=8f2c; Path=/; Secure; HttpOnly; SameSite=Lax 8f2c

A path defaults to / because a cookie without one is sent to the whole origin anyway; SameSite is written only when a caller names it, since the default differs between browsers and pretending to know it here would hide that.

07 — FORMS

A body becomes fields

The two encodings are chosen by Content-Type, and the body is read as whichever one it says. A file part keeps its name, its type and the octets; a text part is the string it decoded to.

moonbit
let body = b"name=ada&role=dev"
let form = @form.parse(body[:], "application/x-www-form-urlencoded"[:])
println(form.field("name").unwrap())
println(form.field("role").unwrap())
ada dev

Because the body came from whoever sent it, both encodings are bounded by a Limits the endpoint owns — a million empty parts and one enormous part are two ways of asking a server to allocate more than it has. A form over its bounds is refused whole rather than truncated: a handler given the first thousand parts of a larger form would answer a request nobody sent.

08 — BYTE RANGES

Ask for a range, get parts

decode reads the field; measure resolves every range against the length, merging overlapping ones and refusing the request as a whole if any of them is unsatisfiable. What comes back is what a 206 is allowed to carry.

moonbit
let specs = @range.decode("bytes=0-499, 1000-"[:])       // two specs
let parts = @range.measure(specs[:], length=10000L)     // two parts
for part in parts.unwrap() {
  println(@range.Content::new(part, complete=10000L).encode())
}
println(@range.unsatisfied(10000L).encode())
bytes 0-499/10000 bytes 1000-9999/10000 bytes */10000

A suffix range is measured from the end, so bytes=-200 on a ten-kilobyte file is 9800-9999. A multi-range result is a body with its own boundary, and @range.parts writes it.

09 — CONDITIONAL

Has the copy changed?

An entity-tag is a hash of the representation, and a precondition is a question about it. Five fields ask, and they are weighed in a fixed order — if a resource has both a date and a tag, the tag decides and the date is not consulted.

moonbit
let tag = @conditional.hash(b"hello"[:], digest=@sha2.Hasher::new())
let ask = @conditional.Ask::read([("If-None-Match", tag.encode())][:])
println(tag.encode())
match ask.evaluate(verb="GET", etag=tag) {
  Fresh => println("304")   // the copy is still good
  Go => println("200")
  Failed => println("412")
}
"5-2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" 304

Fresh answers a 304 with no body; a failed precondition answers 412; Go means the client was not describing a copy at all, so the method is carried out as usual.

10 — CACHE DECISIONS

Four questions, and a clock

A cache decides whether it may store a response, how long that response is fresh, how old it is now, and whether it may answer this request with it. Those are four functions of the fields and a clock; where the octets live is somebody else's problem, and this package does not keep them.

moonbit
let sent = @moondate.http("Sun, 06 Nov 1994 08:49:37 GMT")
let stored = @cache.Entry::read(
  status=200,
  headers=[("Cache-Control", "max-age=60"), ("Date", "Sun, 06 Nov 1994 08:49:37 GMT")],
  request=[],
  sent~,
  received=sent,
)
let now = sent.plus(@moondate.Span::new(seconds=30L))
println(stored.current_age(now~))
match stored.verdict(request=@cache.control, presented=[], now~) {
  Serve => println("serve")
  Validate => println("validate")
  Miss => println("miss")
}
30 serve

Thirty seconds into a sixty-second lifetime, the stored response answers on its own. At ninety it is stale, and the verdict is Validate if the entry has a validator and Miss if it has none. The age figure takes the four clock readings apart.

11 — REDIRECTS

Where next, with what

A redirect is four answers: which URL, which method, whether the body goes with it, and which credentials stop at the origin. The status decides the method — GET and HEAD are unchanged everywhere, while POST becomes a GET on 301, 302 and 303 and stays a POST on 307 and 308.

moonbit
let from = @moonurl.Url::decode("https://example.com/a")
let step = @client.follow(
  status=302,
  location="/b",
  from~,
  verb=@method.Method::Post,
  visited=[from][:],
)
println("\{step.verb.name()} \{step.url.encode()} body=\{step.body}")
GET https://example.com/b body=false

strip lists the field names to drop, and it is empty only while the origin stays the same. A relative Location is resolved against the request that was redirected; a target that has already been visited is a loop, not a hop.

moonbit
// An alternative service, as a server advertises one.
let alternatives = @client.altsvc("h3=\":443\"; ma=3600"[:])

altsvc answers the alternatives in the server's order, or None for clear; best picks the first one this client speaks that has not expired.

12 — WEBSOCKET FRAMES

A frame is a byte layout

Two octets of header, then a length that may grow, then a key the client chose, then the payload. A client masks; a server does not. Reading is a function over a buffer that answers None until the whole frame has arrived.

moonbit
let frame : @ws.Frame = { fin: true, opcode: @ws.Opcode::Text, payload: b"hi" }
let wire = frame.encode(mask=b"\x01\x02\x03\x04")   // 8 octets
let (back, used) = @ws.decode(wire[:], client=true).unwrap()
81 82 01 02 03 04 69 6b payload: "hi"

81 is FIN and the text opcode, 82 is the mask bit and a length of two, and the last two octets are the payload masked with the key. The masking is what makes a frame from a client a frame from a client.

13 — COMPRESSION

One connection, one DEFLATE stream

permessage-deflate is four parameters and a stream that never ends. Each message is stopped with an empty block whose four-octet tail is then removed; the next message carries on inside the same stream, which is why the window survives across messages unless the terms say otherwise.

moonbit
let offers = @pmd.offers("permessage-deflate; client_max_window_bits"[:])
let terms = @pmd.Extension::agree(offers[:]).unwrap()
let writer = @pmd.Writer::new(extension=terms)
let (frame, compressed) = writer.frame(@ws.Opcode::Text, body[:])
let wire = frame.encode_extension(rsv1=compressed)
println(terms.encode(role=@pmd.Role::Server))
permessage-deflate; client_max_window_bits=15

Agreeing to the extension means every message may be compressed, so threshold exists for the caller who would rather not pay for a message under sixty-four octets. On the reading side, limit caps what one message may expand to — the one place a peer can ask for memory it did not pay for.

14 — BOUNDS

Every bound is an argument

Nothing here grows without a limit, and no limit is hidden. Each one has the default the mainstream uses, so a caller who does not care gets a defensible answer and a caller who does care passes a value.

WhereSettingDefault
@formlimits.parts1000
@formlimits.part_size1 MiB
@formstrictoff
@http1limits.head16 KiB
@http1limits.fields100
@pmdlimit16 MiB
@pmdthreshold0
@ssespaceon

A bound that is passed on is Exceeded(limit~, got~), so a refusal says what the ceiling was and what arrived — the two numbers a caller needs to decide whether to raise it.

That is the whole surface in one pass. The reference has every function with its doc comment, and the figures take the byte layouts apart.