Guide
A guided tour
Fourteen 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: 666\n\n", b"data: done\n\n"],
trailers=[("x-digest", "heke1228")],
early_hints=[["; rel=preload"], ["; 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["greeting"] = Json::string("Bonjour")
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("greeting") {
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")
Seven ship with the package: server_errors, trusted_hosts, cors, gzip, https_redirect, proxy_headers, body_limit. Each is a record, so a preset can be adjusted in place: { ..@mid.gzip, level: 1 }.
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())
})
})
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.