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.
moon add moonbitstack/moonasgi
It brings one import name. Everything below is reachable through it.
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:
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.
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()}")
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.
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())
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.
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))
}
}
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.
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")
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.
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}")
An extension the specification does not name goes in the same place, by name, with whatever value the server wants to hand over.
let custom = @moonasgi.Extensions::none().enable("http.fullflush")
println("present=\{custom.get("http.fullflush") is Some(_)}")
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.
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(" ")}")
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.
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"
})
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.
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")
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.
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}")
}
}
}
}
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.
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(", "))
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.
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.
| Request | Reaches | Answer |
|---|---|---|
GET /api/items | api_app, with path=/items | 200 |
GET /mcp | mcp_app, with path=/ | 200 |
GET /nowhere | nothing | 404 |
GET / with host: docs.example.com | docs_app | 200 |
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.
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)
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.
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())
})
})
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.
@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"
})
}
},
)
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.
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())
The seam also checks itself: run_conformance() drives every event, every scope field and every ordering rule through the cores.
let report = @moonasgi.run_conformance()
println("\{report.passed}/\{report.total} 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.
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")
}
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.