moonfetch

Guide · eleven steps

From one request to a kept client.

The library is small on purpose: one call, one client, one body value. This walks from the first line of a program to the octets it puts on the wire, and says which rules it applies without being asked.

01Install

One module, two packages: moonbitstack/moonfetch for HTTP, and moonbitstack/moonfetch/ws for WebSocket.

moon add moonbitstack/moonfetch

Then import what a program needs. Method comes from the HTTP library this one builds on; everything else is under moonfetch.

import {
  "moonbitstack/moonfetch",
  "moonbitstack/moonhttp/method",
}

02One request

The one-shot functions make a client, use it once, and close it. Fine for a script; a program that makes more than a few requests keeps one instead.

let page = @moonfetch.get("https://example.com/")
assert_eq(page.status, 200)
assert_eq(page.is_success(), true)
let text = page.text()

one shot A one-shot call makes a client, uses it once, and closes it. Two calls in a row pay for two connections.

text() decodes the body as UTF-8, json() parses it, body is the octets as they came, and header(name) reads a field without regard to case. checked() turns a 4xx or 5xx into a Refused::Status instead of a value to remember to test.

03A client

A Client keeps idle HTTP/1.1 connections per origin and one HTTP/2 or HTTP/3 connection per origin, which carries every request to it. Close it when the program is done with it.

let api = @moonfetch.Client::new(
  base_url="https://api.example.com/v1",
  headers=[("authorization", "Bearer token")],
  timeout=Some(10000),
)
defer api.close()

base_url is the prefix relative URLs are joined to, and its path is kept: /users under https://a.example/api is https://a.example/api/users. headers go on every request, and a headers given for one request replaces the client's of the same name.

let user = api.get("/users/7").checked()
let made = api.post("/users", body=@moonfetch.Body::Json({ "name": "moon" }))
let listed = api.get("/users", query=[("page", "2"), ("q", "two words")])

query is added to the URL's own query, encoded the way a form is. The methods that carry a body — post, put, patch — take body=; the others do not.

04Bodies

A body is one value. The content type follows from it unless a header names one, so a request cannot carry two bodies for the library to choose between.

aText text/plain; charset=utf-8
bBytes application/octet-stream
cJson application/json, written without spaces
dForm application/x-www-form-urlencoded, in the order given
eMultipart RFC 7578, with a boundary no part contains
fStream produced while the request goes out, chunked on HTTP/1.1
let form = api.post("/login", body=@moonfetch.Body::Form([("user", "moon"), ("pass", "tide")]))
let file = @moonfetch.Part::file("doc", "notes.txt", b"hello file", content_type="text/plain")
let uploaded = api.post("/upload", body=@moonfetch.Body::Multipart([
  @moonfetch.Part::field("owner", "moon"),
  file,
]))

A Source is a body that does not exist yet. A Source::sized declares its length; a Source::new does not, and goes out chunked.

let mut sent = 0
let piecewise = @moonfetch.Source::sized(2048, () => {
  guard sent < 2048 else { return None }
  sent = sent + 1024
  Some(Bytes::make(1024, b'x'))
})
let stored = api.put("/blob", body=@moonfetch.Body::Stream(piecewise))

05Streaming

stream sends the request, follows redirects, and returns as soon as the head is in. Each next gives a piece of the body, or the end with its trailers.

let feed = api.stream(@method.Method::Get, "/events")
for ;; {
  match feed.next() {
    Some(@moonfetch.Data(bytes)) => println(bytes.length())
    Some(@moonfetch.End(trailers)) => {
      println(trailers.length())
      break
    }
    None => break
  }
}

one reader A stream belongs to the task that opened it. Close it, or read it to the end; leaving it open holds a connection's stream.

A stream is not bounded — that is what makes it a stream — so a caller that reads one to the end passes its own bound: feed.bytes(limit=1048576). A buffered request uses Limits::body and stops while the octets are still arriving, not after. Streaming does not ask for compression: a coding would have to be undone on the fly.

06Redirects, timeouts and refusals

Ten redirects by default, five seconds for the whole exchange including them, and one error type for what the library itself refuses.

let seen = api.get("/redirect/3")
assert_eq(seen.url, "https://api.example.com/v1/redirect/0")
let not_followed = api.get("/redirect/3", redirects=0)
assert_eq(not_followed.status, 302)

A 303, and a 301 or 302 answering a POST, become a GET without a body, as browsers do. A 307 and a 308 keep both. authorization and cookie are dropped when a redirect leaves the origin — a jar supplies the cookies the new one has.

let outcome = try {
  ignore(api.get("/slow", timeout=200))
  "answered"
} catch {
  @moonfetch.Refused::Timeout(limit~) => "gave up after \{limit} ms"
  @moonfetch.Refused::Status(status~, ..) => "answered \{status}"
  @moonfetch.Refused::Redirects(limit~) => "more than \{limit} redirects"
  @moonfetch.Refused::Url(why) => why
  @moonfetch.Refused::Exceeded(limit~, ..) => "body over \{limit} octets"
  @moonfetch.Refused::Reset(code~) => "stream reset, HTTP/2 code \{code}"
  @moonfetch.Refused::Proxy(why) => why
  other => other.to_string()
}

A socket, TLS or framing error passes through as itself rather than wrapped: the reason is in the lower layer's error, and hiding it would lose what it says.

07Cookies and proxies

A Jar follows RFC 6265bis: domain and path matching, expiry, the prefix rules, the Public Suffix List. One jar may be shared by several clients and by tasks.

let jar = @moonfetch.Jar::new()
let browser = @moonfetch.Client::new(jar~)
defer browser.close()
ignore(browser.get("https://example.com/login"))
ignore(browser.get("https://example.com/account"))
let proxied = @moonfetch.Client::new(
  proxy=@moonfetch.Proxy::parse("http://user:pass@proxy.internal:3128"),
)
defer proxied.close()

An http request through an HTTP proxy goes in absolute form; an https one is tunnelled with CONNECT. A socks5:// proxy gets the host as a name, so the proxy resolves it. Both carry a username and password when the URL has one. These two blocks are native and wasm only: a browser owns its cookies and its proxy settings.

08HTTP/3

http3=true speaks HTTP/3 over QUIC, for https origins only — there is no HTTP/3 in the clear.

let quic = @moonfetch.Client::new(http3=true, timeout=Some(10000))
defer quic.close()
let trace = quic.get("https://cloudflare.com/cdn-cgi/trace")
assert_eq(trace.version, "HTTP/3")

no switch Nothing here turns certificate checking off — not a flag, not an environment variable, not on either host.

One QUIC connection carries every exchange to an origin, one stream each, so requests overlap as they do on HTTP/2. An origin that says nothing on the datagram within three seconds is spoken to over TCP from then on, and the program does not see the attempt.

A reply is read as it arrives, not a frame at a time. A DATA frame may hold a whole body, so the octets are taken as they come and the room that reading frees goes back to the server as credit while the body is still on its way — which is what lets a body larger than the receive window through.

09WebSocket

moonfetch/ws shares the client's TLS roots, proxy and timeouts, so a wss socket is set up exactly as an https request is.

let chat = @moonfetch.Client::new(timeout=Some(30000))
defer chat.close()
let socket = @ws.connect(chat, "wss://example.com/chat", protocols=["chat"])
socket.send_text("hello")
while socket.recv() is Some(message) {
  match message {
    @ws.Text(text) => println(text)
    @ws.Binary(bytes) => println(bytes.length())
    @ws.Close(status~, ..) => {
      println(status)
      break
    }
  }
}
socket.close(status=1000)

Pings are answered and fragmented messages put back together inside recv, so a caller sees messages, not frames.

10The octets

wire and frames write a request the way the transports do, without sending it — for a log, a test, or a page like this one.

let sent = @moonfetch.wire(@method.Method::Get, "https://example.com/a?b=1")
assert_eq(sent.version, "HTTP/1.1")
assert_eq(
  @utf8.decode_lossy(sent.pieces[0].1[:]),
  "GET /a?b=1 HTTP/1.1\r\nhost: example.com\r\nuser-agent: moonfetch\r\naccept: */*\r\n\r\n",
)

GET /a?b=1 HTTP/1.1 host: example.com user-agent: moonfetch accept: */*

frames gives the same request as HTTP/2 writes it: the connection preface, this side's settings, then one entry per frame, labelled with its type and flags. The ShowCase does this for whatever request you type.

11In a browser

The same package has a second transport, picked at build time: the host's fetch. Nothing in a program changes; what a host does not have is not offered.

aNative, wasm Client::new takes tls?, proxy?, jar?, http2~, h2c~, compression~, idle~
bjs Client::new takes credentials~; connections, cookies and proxies are the host's
cVersion Response::version is empty on js: a browser does not say which version answered
dRedirects a browser follows a cross-origin redirect itself, and does not show the chain
let web = @moonfetch.Client::new(base_url="https://api.example.com", timeout=Some(8000))
let feed = web.get("/feed")

credentials is how a js client says what the host may do with the cookies it keeps: Client::new(credentials=Credentials::Include). close() exists on both, and does nothing on js: the host owns the connections. A program written for one host reads the same on the other, as long as it stays inside what both offer.