moonasgiASGI 3.0

Guide

A guided tour

Fifteen steps, from one handler to a server carrying several applications. Each step is a program you can run, and the output under it is what it printed.

Install

Add the package to a project. There is nothing else to configure: no transport lives in here.

terminal
moon add moonbitstack/moonasgi

It brings one import name. Everything below is reachable through it.

moon.pkg
import {
  "moonbitstack/moonasgi",
}

A few chapters use a subpackage by name — @spec for the event types, @mid for middleware, @dispatch for routing, @testing for the async client, @wsgi for the adapter. Import only the ones you reach for:

moon.pkg
import {
  "moonbitstack/moonasgi",
  "moonbitstack/moonasgi/spec",
  "moonbitstack/moonasgi/mid",
  "moonbitstack/moonasgi/dispatch",
  "moonbitstack/moonasgi/testing",
  "moonbitstack/moonasgi/wsgi",
  "moonbitlang/core/encoding/utf8",
}

A first response

A handler takes a request and returns a response. The TestClient drives one without a socket, so this runs on any backend.

moonbit
let greet : @moonasgi.Handler = fn(_request) {
  @moonasgi.Response::text("hello")
}
let client = @moonasgi.TestClient::new(greet)
let response = client.get("/")
println("\{response.status} \{response.text()}")
output200 hello

Reading a request

The request carries the method, the path, the raw query string, the headers as they arrived, and the body as one buffer. The body is drained before the handler runs, however many events it took.

moonbit
let echo : @moonasgi.Handler = fn(request) {
  let query = @moonasgi.latin1_decode(request.query_string)
  @moonasgi.Response::text(
    "\{request.http_method} \{request.path}?\{query} \{request.body.length()} B",
  )
}
let client = @moonasgi.TestClient::new(echo)
println(client.get("/items?q=moonbit").text())
println(client.post("/orders", body=b"n=1").text())
outputGET /items?q=moonbit 0 B POST /orders? 3 B

Bytes arrive as Bytes, and headers stay a list of pairs, so a repeated header survives. Nothing is folded into a map behind your back.

The scope

A handler does not see everything. A mounted application needs root_path, security needs the peer address, and a feature needs to know what the server advertises. run_http_scoped hands over the whole scope instead.

moonbit
let app = fn(scope : @moonasgi.HttpScope, body : Bytes) {
  let peer = match scope.client {
    Some((host, _)) => host
    None => "-"
  }
  [
    @spec.HttpResponseStart(status=200, headers=[], trailers=false),
    @spec.HttpResponseBody(
      body=@utf8.encode("\{scope.root_path} · \{peer} · \{body.length()} B"),
      more_body=false,
    ),
  ]
}
let scope = @moonasgi.Scope::Http(
  @moonasgi.HttpScope::new(
    http_method="GET",
    path="/greet",
    root_path="/api",
    client=("203.0.113.7", 52333),
  ),
)
let out = @moonasgi.run_http_scoped(app, scope, [
  @spec.HttpRequest(body=b"hi", more_body=false),
])
for event in out {
  if event is @spec.HttpResponseBody(body~, ..) {
    println(@utf8.decode_lossy(body))
  }
}
output/api · 203.0.113.7 · 2 B

There is also run_http_app, the same call at request level, and run_http_scoped_drain, which also tells the application whether the client vanished or the body was cut short.

Streaming

A StreamingResponse sends its body in pieces, may announce hints before the headers, and may close with trailers. events lowers it to the exact sequence a server writes.

moonbit
let feed : @moonasgi.StreamHandler = fn(_request) {
  @moonasgi.StreamingResponse::new(
    status=200,
    headers=[("content-type", "text/event-stream")],
    chunks=[b"data: 233\n\n", b"data: done\n\n"],
    trailers=[("x-digest", "9f2c")],
    early_hints=[["; rel=preload"]],
  )
}
let client = @moonasgi.TestClient::from_stream(feed)
let response = client.get("/events")
println(response.text())
println(response.trailer("x-digest").unwrap_or("-"))
println("\{response.early_hints.length()} early hints")
outputdata: 233 data: done 9f2c 1 early hints

The client reassembles the chunks into one body and keeps the trailers and hints apart, so a test can assert on any of them.

Extensions

The optional parts of the specification are typed here too: push, path send, zero-copy send, early hints, debug, trailers, and the WebSocket denial. A server advertises what it honours on the scope, and the application reads the flags.

moonbit
let extensions = @moonasgi.Extensions::none()
  .enable_push()
  .enable_pathsend()
  .enable_early_hint()
println("push=\{extensions.http_response_push} pathsend=\{extensions.http_response_pathsend}")
println("early_hint=\{extensions.http_response_early_hint}")
outputpush=true pathsend=true early_hint=true

An extension the specification does not name goes in the same place, by name, with whatever value the server wants to hand over.

moonbit
let custom = @moonasgi.Extensions::none().enable("http.fullflush")
println("present=\{custom.get("http.fullflush") is Some(_)}")
outputpresent=true

WebSocket

A WebSocket handler is three functions: what to do on connect, what to answer for a frame, what to do when the client leaves. ws_run and the client drive a whole connection in-process.

moonbit
let handler = @moonasgi.WebSocketHandler::new(
  on_receive=fn(message) {
    match message {
      Text(text) => [SendText("echo:" + text)]
      Binary(bytes) => [SendBinary(bytes)]
    }
  },
)
let app : @moonasgi.Handler = fn(_request) { @moonasgi.Response::text("") }
let session = @moonasgi.TestClient::new(app).websocket(
  path="/chat",
  handler~,
  send=[Text("hi"), Text("there")],
)
println("accepted=\{session.accepted} texts=\{session.texts().join(" ")}")
outputaccepted=true texts=echo:hi echo:there

Connect can also answer with a close code, or with a whole HTTP response when the handshake should be refused: Accept, Reject, DenyHttp.

Lifespan

Startup runs once, before any request, and seeds scope.state in place. The server copies that map onto every request scope, which is how a pool or a config reaches a handler.

moonbit
let lifecycle = @moonasgi.LifespanHandler::new(
  on_startup=fn(scope) {
    scope.state["db_pool"] = Json::string("pool://main")
    Complete
  },
)
let scope = @spec.LifespanScope::new()
let replies = @moonasgi.run_lifespan(lifecycle, Lifespan(scope), [
  @spec.LifespanStartup,
  @spec.LifespanShutdown,
])
for reply in replies {
  println(match reply {
    LifespanStartupComplete => "startup.complete"
    LifespanShutdownComplete => "shutdown.complete"
    _ => "other"
  })
}
println(match scope.state.get("db_pool") {
  Some(Json::String(s)) => s
  _ => "none"
})
outputstartup.complete shutdown.complete pool://main

A startup that returns Failed(message) ends the boot: no shutdown follows, and the message is what the server reports.

Middleware

A middleware wraps an application, and stack folds a list of them from the last to the first. The layer given first sees the request first and the response last.

moonbit
let app : @moonasgi.AsgiApp = async fn(_, receive, send) {
  ignore(receive())
  send(HttpResponseStart(status=200, headers=[("content-type", "text/plain")], trailers=false))
  send(HttpResponseBody(body=Bytes::make(4096, b'a'), more_body=false))
}
let scope = @spec.HttpScope::new(
  http_method="GET",
  path="/",
  headers=[("host", "example.com"), ("accept-encoding", "gzip")],
)
let hits = Ref(0)
let counted = @mid.Wrap(inner => {
  async fn(scope, receive, send) {
    hits.val = hits.val + 1
    inner(scope, receive, send)
  }
})
let stacked = @mid.stack(
  [
    @mid.server_errors,
    @mid.TrustedHosts::new(hosts=["example.com"]),
    @mid.Cors::new(origins=["https://app.example.com"], methods=["GET", "PUT"]),
    @mid.gzip,
    counted,
  ],
  app,
)
let answer : Array[@spec.Event] = []
stacked(
  Http(scope),
  async fn() { HttpRequest(body=b"", more_body=false) },
  async fn(event) { answer.push(event) },
)
println("layers ran \{hits.val} times")
outputlayers ran 1 times

Ten ship with the package: server_errors, trusted_hosts, cors, gzip, https_redirect, proxy_headers, body_limit, rate_limit, sessions, auth. Each is a record, so a preset can be adjusted in place: { ..@mid.gzip, level: 1 }. cors takes an origin_regex as well as a list, for a site whose origins cannot be listed.

Telling a client what is left

A client that knows its quota can slow down before it is refused. rate_limit says so in the two fields of draft-ietf-httpapi-ratelimit-headers when headers is on: RateLimit-Policy for what the policy grants, RateLimit for what this request leaves. Both are off by default, since they cost a header pair on every response.

moonbit
let now = Ref(0L)
let app : @moonasgi.AsgiApp = async fn(_, receive, send) {
  ignore(receive())
  send(HttpResponseStart(status=200, headers=[], trailers=false))
  send(HttpResponseBody(body=b"hello", more_body=false))
}
let limited = @mid.RateLimit::new(
  rate=2.0,
  burst=8.0,
  headers=true,
  clock=fn() { now.val },
).wrap(app)
let answer : Array[@spec.Event] = []
limited(
  Http(
    @spec.HttpScope::new(
      http_method="GET",
      path="/",
      client=("203.0.113.7", 50000),
    ),
  ),
  async fn() { HttpRequest(body=b"", more_body=false) },
  async fn(event) { answer.push(event) },
)
for event in answer {
  if event is HttpResponseStart(headers~, ..) {
    for header in headers {
      if header.0.has_prefix("ratelimit") {
        println("\{header.0}: \{header.1}")
      }
    }
  }
}
outputratelimit-policy: "default";q=8;w=4 ratelimit: "default";r=7;t=1

Eight requests over the four seconds the bucket takes to fill, seven left after this one, and half a second before the eighth returns — written as one second, since the field counts whole ones. A refused answer states r=0 and the same moment as its Retry-After. The two records behind those lines, RateLimitPolicy and ServiceLimit, encode a field on their own for a server with several policies or a limiter of its own.

Sessions and identity

A session that lives in the cookie needs no store on the server: the data travels with the client, signed, and the request that carries it gets it back. session is the map a handler reads and writes — the same map the layer writes back into the answer.

moonbit
let sessions : @mid.Sessions = @mid.Sessions::new(
  secret=@utf8.encode("a secret only the server knows"),
  clock=fn() { 1_700_000_000L },
)
let app : @moonasgi.AsgiApp = async fn(scope, receive, send) {
  ignore(receive())
  match scope {
    Http(hs) if hs.path == "/login" && hs.http_method == "POST" => {
      @mid.session(scope)["user"] = Json::string("ada")
      send(HttpResponseStart(status=303, headers=[("location", "/me")], trailers=false))
    }
    Http(_) => {
      let who = match @mid.user(scope) {
        Some(Object(fields)) => match fields.get("name") { Some(String(name)) => name; _ => "nobody" }
        _ => "nobody"
      }
      send(HttpResponseStart(status=200, headers=[], trailers=false))
      send(HttpResponseBody(body=@utf8.encode("you are " + who), more_body=false))
      return
    }
    _ => return
  }
  send(HttpResponseBody(body=b"", more_body=false))
}
let from_session : &@mid.Backend = @mid.BackendFn(fn(scope) {
  match @mid.session(scope).get("user") {
    Some(name) => @mid.Identity::of(Json::object(Map([("name", name)])))
    None => @mid.Identity::anonymous()
  }
})
let service = sessions.wrap(@mid.Auth::new(from_session).wrap(app))
let client = @testing.Client::new(service, follow_redirects=true, clock=fn() { 1_700_000_000L })
println("before logging in:     " + client.get("/me").text())
println("after logging in:      " + client.post("/login").text())
println("the jar holds:         " + client.cookies().map(fn(p) { p.0 }).join(", "))
outputbefore logging in: you are nobody after logging in: you are ada the jar holds: session

The answer to the login carries Set-Cookie: session=…; Path=/; Max-Age=1209600; HttpOnly; SameSite=lax, and the next request carries it back. The signature is the timestamped HMAC form Python's services already use, so a cookie written on either side is readable on the other; a cookie edited by the client, or one older than max_age, reads as no session at all. Emptying the session answers with a cookie that expired at the epoch, and the client drops it.

Identity is a separate step, so one backend answers it for every route. Auth puts what the backend returned on the scope under user and auth — holding null when nobody was identified, which is how a handler tells that apart from a service with no auth layer at all. The session layer goes outside the auth layer, since the backend reads what it wrote.

The whole flow is src/examples/19-session; the test client keeps the cookies for you, and client.cookies() shows what it holds.

Several applications behind one

One server can carry an API, an MCP endpoint and a site at the same time. mount picks by path prefix and hands the chosen application the path below it; hosts picks by the Host header.

moonbit
let app_of = fn(name : String) -> @moonasgi.AsgiApp {
  async fn(_, receive, send) {
    ignore(receive())
    send(HttpResponseStart(status=200, headers=[("x-app", name)], trailers=false))
    send(HttpResponseBody(body=@utf8.encode(name), more_body=false))
  }
}
let app = @dispatch.mount([
  ("/api", app_of("api")),
  ("/mcp", app_of("mcp")),
])
let by_host = @dispatch.hosts(
  [("docs.example.com", app_of("docs"))],
  fallback=app,
)

The mounted application sees path below its prefix and root_path with the prefix added. A path nothing takes is answered 404, or a declined handshake on a WebSocket. The lifespan reaches every application: startup in order, shutdown in reverse, and a failed startup fails them all.

RequestReachesAnswer
GET /api/itemsapi_app, with path=/items200
GET /mcpmcp_app, with path=/200
GET /nowherenothing404
GET / with host: docs.example.comdocs_app200

Synchronous applications

An application written the old way takes a mapping of CGI variables, calls start_response, then yields the body. It runs on the same events as everything else.

moonbit
let legacy : @wsgi.WsgiApp = (environ, start_response) => {
  ignore(start_response("200 OK", [("Content-Type", "text/plain")]))
  [@utf8.encode("hello from "), @utf8.encode(environ.path_info)].iter()
}
let app : @moonasgi.AsgiApp = @wsgi.to_asgi(legacy)
served one GET /legacy/greet200 · 17 B · hello from /greet

Each chunk is sent as it is yielded, so a long body still streams. A WebSocket is declined, and the lifespan is answered.

Testing an application

An application written directly on AsgiApp is tested with testing. It runs the application for real, around its lifespan, and an error it raises reaches the test. Here is one application, in all three shapes, and the test that drives it.

moonbit
let app : @moonasgi.AsgiApp = async fn(scope, receive, send) {
  match scope {
    Http(hs) => {
      ignore(receive())
      let (status, body) = if hs.path == "/items" { (201, b"created") } else { (404, b"no") }
      send(HttpResponseStart(status~, headers=[], trailers=false))
      send(HttpResponseBody(body~, more_body=false))
    }
    WebSocket(_) => {
      ignore(receive())
      send(WebSocketAccept(subprotocol=None, headers=[]))
      guard receive() is WebSocketReceive(text~, ..) else { return }
      send(WebSocketSendText(text.unwrap_or("")))
      send(WebSocketClose(code=1000, reason=""))
    }
    Lifespan(_) => {
      ignore(receive())
      send(LifespanStartupComplete)
      ignore(receive())
      send(LifespanShutdownComplete)
    }
  }
}
let client = @testing.Client::new(app)
client.lifespan(() => {
  let created = client.post("/items", body=b"{}")
  println("POST /items -> \{created.status}")
  client.websocket(path="/chat", ws => {
    ws.send_text("hi")
    println("echo -> " + ws.receive_text())
  })
})
outputPOST /items -> 201 echo -> hi

The client keeps a cookie jar: whatever a response set is sent with the next request to that host and path, and follow_redirects — off by default, so a test that asserts on a 302 still sees it — walks a redirect chain, turning a 303 into a GET as a browser does. A secure cookie is held back unless the client was built with scheme="https".

To watch one exchange event by event, communicate hands you the application's inbox and outbox.

moonbit
@testing.communicate(
  app,
  @moonasgi.HttpScope::new(http_method="POST", path="/items") |> Http,
  c => {
    c.send(HttpRequest(body=b"", more_body=false))
    for _ in 0..<2 {
      println(match c.receive() {
        HttpResponseStart(status~, ..) => "http.response.start \{status}"
        HttpResponseBody(body~, ..) => "http.response.body \{body.length()} B"
        _ => "other"
      })
    }
  },
)
outputhttp.response.start 201 http.response.body 7 B

Keeping it honest

A server can check what an application emits before writing any of it to a socket. validate_events walks a stream and names the first violation.

moonbit
let scope = @moonasgi.Scope::Http(
  @moonasgi.HttpScope::new(http_method="GET", path="/"),
)
let wrong = [@spec.HttpResponseBody(body=b"x", more_body=false)]
println(@moonasgi.validate_events(scope, wrong).unwrap().describe())
outputresponse body before http.response.start

The seam also checks itself: run_conformance() drives every event, every scope field and every ordering rule through the cores.

moonbit
let report = @moonasgi.run_conformance()
println("\{report.passed}/\{report.total} checks passed")
output436/436 checks passed

HTTP/2 request lines

On a multiplexed transport the request line arrives as pseudo-headers, mixed in with the ordinary fields. Lowering them is part of the seam, so an application never sees a colon-prefixed header.

moonbit
let lowered = @moonasgi.HttpScope::from_h2_headers([
  (":method", "POST"),
  (":scheme", "https"),
  (":authority", "example.test"),
  (":path", "/items?page=2"),
  ("content-type", "application/json"),
])
match lowered {
  Ok(scope) => {
    println("\{scope.http_method} \{scope.path}")
    println(@moonasgi.latin1_decode(scope.query_string))
    println(scope.headers.iter().map(h => "\{h.0}: \{h.1}").join(", "))
  }
  Err(UnknownPseudoHeader(name)) => println("unknown " + name)
  Err(_) => println("malformed request line")
}
outputPOST /items page=2 host: example.test, content-type: application/json

A malformed set is refused with the first thing wrong with it: a missing required header, a duplicate, an unknown name, or a pseudo-header after an ordinary one.