MoonBit-dialect ASGI 3.0 — the load-bearing server↔app SEAM (Scope / Receive / Send) that the moon* full-stack web suite (mooncat, moonapi, moonrpc, moongql, moonzero) is built around.
flowchart LR
net["native async<br/>(sockets, TLS, HTTP/1.1, HTTP/2)"] --> cat["mooncat<br/>(ASGI server)"]
cat -->|"Scope / Receive / Send"| asgi(["**moonasgi**<br/>SEAM"])
asgi --> api["moonapi"]
asgi --> gql["moongql"]
asgi --> zero["moonzero"]
asgi --> mcp["moonmcp"]pub enum Scope {
Http(HttpScope)
WebSocket(WebSocketScope)
Lifespan(LifespanScope)
}
pub type Receive = async () -> Event // pull the next inbound event
pub type Send = async (Event) -> Unit // push an outbound event
pub type AsgiApp = async (Scope, Receive, Send) -> Unitpub type Handler = (Request) -> Response
pub type Middleware = (Handler) -> Handlerlet client = TestClient::new(handler) // or ::from_stream / ::from_app
let r = client.post("/echo", body=b"payload")
assert_eq(r.status, 200)
assert_eq(r.text(), "payload")let client = @testing.Client::new(app)
client.lifespan(() => { // startup, body, shutdown
let r = client.post("/items", body=b"{}")
assert_eq(r.status, 201)
client.websocket(path="/chat", subprotocols=["v1"], ws => {
ws.send_text("hi")
assert_eq(ws.receive_text(), "hi")
}) // closes with 1000, waits for the app
})@testing.communicate(app, scope, c => {
c.send(HttpRequest(body=b"", more_body=false))
assert_true(c.receive() is HttpResponseStart(status=200, ..))
assert_true(c.receive_nothing())
c.wait()
})| asgiref / Starlette | Here | Default wait |
|---|---|---|
| ApplicationCommunicator(app, scope) | communicate(app, scope, body); the application is cancelled when body returns | — |
| send_input, receive_output, receive_nothing, wait | send, receive, receive_nothing, wait | 1 s; 100 ms for receive_nothing |
| TestClient(app), with client: | Client::new(app), client.lifespan(body) | 1 s per wait |
| client.websocket_connect(path) | client.websocket(path~, body) with a Session | — |
let client = TestClient::new(handler)
let session = client.websocket(
path="/chat",
handler=WebSocketHandler::echo(subprotocol="chat"),
send=[Text("hello"), Binary(b"\x01\x02")],
)
assert_eq(session.accepted, true)
assert_eq(session.subprotocol, Some("chat"))
assert_eq(session.texts(), ["hello"])let app = LifespanHandler::new(
on_startup=fn(scope) {
scope.state["db_pool"] = Json::string("pool://greet") // seeded in place
Complete
},
)
let out = run_lifespan(app, Lifespan(scope), [LifespanStartup])
assert_eq(out == [LifespanStartupComplete], true)run_http_scoped(fn(scope, body) {
let prefix = scope.state.get("greeting_prefix") // lifespan-seeded
[HttpResponseStart(status=200, headers=[], trailers=false),
HttpResponseBody(body, more_body=false)]
}, Http(scope), inbound)let scope = HttpScope::from_h2_headers([
(":method", "POST"), (":scheme", "https"),
(":authority", "example.test"), (":path", "/items?page=2"),
("content-type", "application/json"),
])
// -> Ok: http_method="POST", scheme="https", path="/items",
// query_string=b"page=2", headers=[("host","example.test"), ...]let report = run_conformance()
assert_eq(report.ok(), true)let bad = [HttpResponseBody(body=b"x", more_body=false)] // no start
assert_eq(validate_events(scope, bad) == Some(BodyBeforeStart), true)| Spec feature | Modelled as | Covered by |
|---|---|---|
| http scope (all fields incl. state, raw_path, root_path) | HttpScope | conformance scope/http-fields |
| websocket scope (incl. subprotocols, state) | WebSocketScope | conformance scope/ws-fields |
| lifespan scope (incl. state) | LifespanScope | conformance scope/lifespan-fields |
| asgi version / spec_version (2.5 http+ws, 2.0 lifespan) | AsgiVersion | conformance spec_version/*; test "per-subprotocol asgi spec_version defaults" |
| spec_version numeric negotiation | AsgiVersion::at_least | test "AsgiVersion negotiates spec_version numerically" |
| Every http/ws/lifespan message, both directions | Event (25 variants) | conformance event/*; test "SEAM scope and event variants construct" |
| Full scope reaches a framework (state, root_path, extensions) | run_http_scoped | conformance scoped/*; test "greet: moonapi http routing round-trips…" |
| lifespan.startup / .shutdown (complete / failed, in-place state) | LifespanHandler / run_lifespan | conformance lifespan/*; test "greet: lifespan startup seeds state…" |
| http.request drain (streamed more_body) | run_http / TestClient | test "TestClient streams a chunked request body into the handler" |
| http.disconnect | HttpDisconnect | conformance event/* |
| http.response.start + .body round-trip | Response / run_http | conformance http/roundtrip; test "run_http drains a chunked body…" |
| Response streaming (more_body: true chunks) | StreamingResponse / run_http_stream | test "TestClient reassembles a streaming multi-chunk response body" |
| websocket.connect / .accept / .receive / .send / .close | WebSocketHandler / ws_run | test "ws_run echoes text and binary frames…" |
| websocket.disconnect (2.5 reason) | WebSocketDisconnect | test "on_disconnect receives the 2.5 close code and reason" |
| Handshake reject (bare close) | WsAccept::Reject | test "ws handler can reject the handshake with a close code" |
| ext http.response.trailers | StreamingResponse.trailers | test "TestClient captures response trailers" |
| ext http.response.push | HttpResponsePush | test "TestClient captures server push and pathsend" |
| ext http.response.pathsend | HttpResponsePathSend | test "TestClient captures server push and pathsend" |
| ext http.response.zerocopysend | HttpResponseZeroCopySend | test "zerocopysend and debug extensions round-trip…" |
| ext http.response.early_hint | HttpResponseEarlyHint | test "early-hint extension round-trips through the TestClient" |
| ext http.response.debug | HttpResponseDebug | test "zerocopysend and debug extensions round-trip…" |
| ext websocket.http.response (denial) | WsAccept::DenyHttp | test "ws handler can deny the handshake with a full HTTP response" |
| ext tls | TlsExtension | test "Extensions builder advertises capabilities and TLS data" |
| Message-ordering rules (all message sets) | validate_events | conformance order/*; test "validator names the violation…" |
| HTTP/2·3 pseudo-header → scope lowering (:method/:scheme/:authority/:path, host synthesis, malformed rejection) | HttpScope::from_h2_headers / Http2HeaderError | conformance h2/* |
| http_version transport-agnostic seam ("1.1" / "2" / "3") | HttpScope.http_version | conformance h2/seam-agnostic |
let app = @mid.stack(
[
@mid.ServerErrors::new(), // outermost
@mid.TrustedHosts::new(hosts=["example.com", "*.example.com"]),
@mid.Cors::new(origins=["https://app.example.com"], methods=["GET", "POST"]),
@mid.gzip,
@mid.Wrap(inner => my_timing(inner)),
],
framework_app,
)| Middleware | As in Starlette | Defaults |
|---|---|---|
| Cors | CORSMiddleware: preflights answered here (200, or 400 naming the refusal), the origin echoed with credentials or a cookie | no origin, GET, the safelisted headers, 600 s |
| Gzip | GZipMiddleware: a single-message body of minimum octets or more, when gzip is accepted | 500 octets, level 9 |
| TrustedHosts | TrustedHostMiddleware: other hosts get 400; a bare domain goes to its listed www. | any host, redirect on |
| HttpsRedirect | HTTPSRedirectMiddleware: http to https, ws to wss, by 307 | — |
| ServerErrors | ServerErrorMiddleware: a 500 if the answer has not started, then the error raised on | no detail; debug or a handler to say more |
| asgiref | Here |
|---|---|
| compatibility | guarantee_single_callable, double_to_single_callable; the convention is a constructor (AsgiApplication), since MoonBit has no signature reflection |
| typing | spec: every scope and message as a type |
| testing.ApplicationCommunicator | testing.communicate and Communicator; testing.Client beside it as Starlette's TestClient, and the synchronous TestClient for handlers |
| sync, wsgi, server, local | nothing to carry over: MoonBit has no threads to bridge, no WSGI, and the server is mooncat |
| Extensions | push, pathsend, zero-copy send, trailers, early hints, debug, WebSocket denial, TLS |
fn guarantee_single_callable(app : AsgiApplication) -> (async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)fn latin1_decode(bytes : Bytes) -> Stringfn latin1_encode(s : String) -> Bytesfn percent_decode(target : String) -> BytesInstall
Download zipMoonBit-dialect ASGI 3.0 — the load-bearing server↔app SEAM (Scope / Receive / Send) that the moon* full-stack web suite (mooncat, moonapi, moonrpc, moongql, moonzero) is built around.