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 --> rpc["moonrpc"]
asgi --> gql["moongql"]
asgi --> zero["moonzero"]
api --> orm["moonorm"]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 = TestClient::new(handler)
let session = client.websocket(
path="/chat",
handler=WebSocketHandler::echo(subprotocol=Some("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 |
pub(all) enum Event {
HttpRequest(body~ : Bytes, more_body~ : Bool)
HttpDisconnect
HttpResponseStart(status~ : Int, headers~ : Array[(String, String)], trailers~ : Bool)
HttpResponseBody(body~ : Bytes, more_body~ : Bool)
HttpResponseTrailers(headers~ : Array[(String, String)], more_trailers~ : Bool)
HttpResponsePush(path~ : String, headers~ : Array[(String, String)])
HttpResponsePathSend(path~ : String)
HttpResponseZeroCopySend(fd~ : Int, offset~ : Int?, count~ : Int?, more_body~ : Bool)
HttpResponseDebug(info~ : Json)
HttpResponseEarlyHint(links~ : Array[String])
WebSocketConnect
WebSocketReceive(text~ : String?, bytes~ : Bytes?)
WebSocketDisconnect(code~ : Int, reason~ : String?)
WebSocketAccept(subprotocol~ : String?, headers~ : Array[(String, String)])
WebSocketSendText(String)
WebSocketSendBytes(Bytes)
WebSocketClose(code~ : Int, reason~ : String)
WebSocketHttpResponseStart(status~ : Int, headers~ : Array[(String, String)])
WebSocketHttpResponseBody(body~ : Bytes, more_body~ : Bool)
LifespanStartup
LifespanShutdown
LifespanStartupComplete
LifespanStartupFailed(message~ : String)
LifespanShutdownComplete
LifespanShutdownFailed(message~ : String)
} derive(Eq)pub(all) enum EventOrderError {
BodyBeforeStart
DuplicateResponseStart
EarlyHintAfterStart
BodyAfterComplete
UnexpectedTrailers
TrailersBeforeBody
MissingResponseStart
IncompleteBody
MissingTrailers
EventAfterComplete
FrameBeforeAccept
DuplicateAccept
EventAfterClose
DenialBodyBeforeStart
DenialAfterAccept
AcceptAfterDenial
IncompleteDenial
MissingHandshakeReply
DuplicateLifespanReply
ShutdownBeforeStartup
NonResponseEvent
} derive(Eq)pub(all) struct Extensions {
tls : TlsExtension?
http_response_push : Bool
http_response_trailers : Bool
http_response_pathsend : Bool
http_response_early_hint : Bool
websocket_http_response : Bool
} derive(Eq)pub(all) enum Http2HeaderError {
MissingMethod
MissingScheme
MissingPath
EmptyPath
DuplicatePseudoHeader(String)
UnknownPseudoHeader(String)
PseudoHeaderAfterRegular(String)
} derive(Eq)pub(all) struct HttpScope {
http_version : String
http_method : String
scheme : String
path : String
raw_path : Bytes
query_string : Bytes
root_path : String
headers : Array[(String, String)]
client : (String, Int)?
server : (String, Int)?
asgi : AsgiVersion
extensions : Extensions
state : Map[String, Json]
}fn HttpScope::from_h2_headers(headers : Array[(String, String)], http_version? : String, root_path? : String, client? : (String, Int)?, server? : (String, Int)?, extensions? : Extensions, asgi? : AsgiVersion, state? : Map[String, Json]) -> Result[HttpScope, Http2HeaderError]fn HttpScope::new(http_method~ : String, path~ : String, http_version? : String, scheme? : String, raw_path? : Bytes, query_string? : Bytes, root_path? : String, headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int)?, asgi? : AsgiVersion, extensions? : Extensions, state? : Map[String, Json]) -> HttpScopepub(all) struct LifespanHandler {
on_startup : (LifespanScope) -> LifespanReply
on_shutdown : (LifespanScope) -> LifespanReply
}fn LifespanHandler::new(on_startup? : (LifespanScope) -> LifespanReply, on_shutdown? : (LifespanScope) -> LifespanReply) -> LifespanHandlerfn StreamingResponse::new(chunks~ : Array[Bytes], status? : Int, headers? : Array[(String, String)], trailers? : Array[(String, String)], early_hints? : Array[Array[String]]) -> StreamingResponsepub(all) struct TestClient {
app : (Request) -> Array[Event]
root_path : String
base_headers : Array[(String, String)]
client : (String, Int)?
server : (String, Int)?
extensions : Extensions
}fn TestClient::delete(self : TestClient, path : String, headers? : Array[(String, String)]) -> TestResponsefn TestClient::from_app(app : (Request) -> Array[Event], root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int)?, extensions? : Extensions) -> TestClientfn TestClient::from_stream(handler : (Request) -> StreamingResponse, root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int)?, extensions? : Extensions) -> TestClientfn TestClient::get(self : TestClient, path : String, headers? : Array[(String, String)]) -> TestResponsefn TestClient::head(self : TestClient, path : String, headers? : Array[(String, String)]) -> TestResponsefn TestClient::new(handler : (Request) -> Response, root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int)?, extensions? : Extensions) -> TestClientfn TestClient::patch(self : TestClient, path : String, body? : Bytes, headers? : Array[(String, String)]) -> TestResponsefn TestClient::post(self : TestClient, path : String, body? : Bytes, headers? : Array[(String, String)]) -> TestResponsefn TestClient::put(self : TestClient, path : String, body? : Bytes, headers? : Array[(String, String)]) -> TestResponsefn TestClient::request(self : TestClient, http_method~ : String, path~ : String, headers? : Array[(String, String)], body? : Bytes, http_version? : String, chunks? : Array[Bytes]) -> TestResponsefn TestClient::websocket(self : TestClient, path~ : String, handler~ : WebSocketHandler, send? : Array[WsMessage], headers? : Array[(String, String)], subprotocols? : Array[String], disconnect? : Int?) -> WsTestSessionfn TestClient::websocket_app(self : TestClient, path~ : String, app~ : (WebSocketScope, Array[Event]) -> Array[Event], send? : Array[WsMessage], headers? : Array[(String, String)], subprotocols? : Array[String], disconnect? : Int?) -> WsTestSessionpub(all) struct TestResponse {
status : Int
headers : Array[(String, String)]
body : Bytes
trailers : Array[(String, String)]
pushes : Array[PushPromise]
pathsend : String?
zerocopysend : ZeroCopySend?
debug : Array[Json]
early_hints : Array[Array[String]]
} derive(Eq)fn WebSocketHandler::new(on_connect? : (WebSocketScope) -> WsAccept, on_receive? : (WsMessage) -> Array[WsSend], on_disconnect? : (Int, String?) -> Array[WsSend]) -> WebSocketHandlerpub(all) struct WebSocketScope {
http_version : String
scheme : String
path : String
raw_path : Bytes
query_string : Bytes
root_path : String
headers : Array[(String, String)]
client : (String, Int)?
server : (String, Int)?
subprotocols : Array[String]
asgi : AsgiVersion
extensions : Extensions
state : Map[String, Json]
}fn WebSocketScope::new(path~ : String, http_version? : String, scheme? : String, raw_path? : Bytes, query_string? : Bytes, root_path? : String, headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int)?, subprotocols? : Array[String], asgi? : AsgiVersion, extensions? : Extensions, state? : Map[String, Json]) -> WebSocketScopepub(all) enum WsSend {
SendText(String)
SendBinary(Bytes)
Close(code~ : Int, reason~ : String)
} derive(Eq)pub(all) struct WsTestSession {
accepted : Bool
subprotocol : String?
accept_headers : Array[(String, String)]
messages : Array[WsMessage]
closed : Bool
close_code : Int?
close_reason : String?
denial : TestResponse?
} derive(Eq)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.