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.
moon add moonbitstack/moonhttp
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.
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.
// 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}")
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.
request.field("if-none-match") // Some(b'"v7"')
request.field("if-none-match").unwrap()[:] // b'"v7"', quotes included
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.
let not_modified = @http1.Response::of(
304,
fields=[@header.Header::of("ETag", "\"v7\"")],
reason="Not Modified",
)
let wire = not_modified.encode()
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.
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.
| Method | Safe | Idempotent | What it is for |
|---|---|---|---|
| GET | yes | yes | the representation itself |
| HEAD | yes | yes | the fields, without the body |
| POST | no | no | whatever the target does with it |
| PUT | no | yes | replace the representation |
| PATCH | no | no | a delta to the representation |
| DELETE | no | yes | remove it |
| yes | yes | what may be asked of it | |
| TRACE | yes | yes | the request as it was received |
| CONNECT | no | no | a tunnel through the origin |
let verb = @method.Method::of("PUT"[:])
verb.is_safe() // false
verb.is_idempotent() // true — two PUTs, one result
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.
let frame = @sse.Event::new(data="{\"seq\":1}", kind="tick", id="1").encode()
let events = @sse.events(frame[:])
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.
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.
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)
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.
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.
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())
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.
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.
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())
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.
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.
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")
}
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.
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.
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")
}
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.
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.
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}")
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.
// 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.
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.
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 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.
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.
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))
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.
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.
| Where | Setting | Default |
|---|---|---|
| @form | limits.parts | 1000 |
| @form | limits.part_size | 1 MiB |
| @form | strict | off |
| @http1 | limits.head | 16 KiB |
| @http1 | limits.fields | 100 |
| @pmd | limit | 16 MiB |
| @pmd | threshold | 0 |
| @sse | space | on |
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.