moonasgi
MoonBit-dialect ASGI 3.0 — the load-bearing server↔app seam every server and framework in the moon* full-stack suite is built around. One typed contract isolates async transport churn in a single server adapter.
moon add moonbitstack/moonasgi✶ The contract at a glance
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) -> Unit pub type Handler = (Request) -> Response // the ergonomic sugar pub type Middleware = (Handler) -> Handler
§Core SEAM
The load-bearing ASGI 3.0 seam: the typed Scope / Event contract and the async Receive / Send / AsgiApp callables every server and framework binds to.
enum Scope
The connection scope: one value per HTTP request, WebSocket connection, or lifespan run. It carries the immutable connection metadata a server hands to an application, mirroring ASGI 3.0's scope dict as a typed sum.
struct AsgiVersion
The asgi sub-dict every scope carries: the ASGI protocol version ("3.0") and the spec_version of the concrete http / websocket / lifespan sub-spec the server implements. Applications negotiate optional behaviour against spec_version with at_least.
fn AsgiVersion::default() -> AsgiVersion
The default ASGI handshake: protocol 3.0, sub-spec 2.5 (the current HTTP / WebSocket spec revision, 2024-06-05). An alias of http — the common case.
fn AsgiVersion::http() -> AsgiVersion
The HTTP handshake: protocol 3.0, HTTP sub-spec 2.5 — the www sub-spec's current revision, whose own history runs 2.1 websocket accept headers, 2.2 a None server port, 2.3 a websocket close reason, 2.4 send() raising on a closed connection, 2.5 a websocket disconnect reason. Of those, 2.4 is the one this seam cannot express: see the note in the README.
fn AsgiVersion::websocket() -> AsgiVersion
The WebSocket handshake: protocol 3.0, WebSocket sub-spec 2.5 — the revision that adds reason to the disconnect event, alongside state and the websocket.http.response denial extension.
fn AsgiVersion::lifespan() -> AsgiVersion
The Lifespan handshake: protocol 3.0, lifespan sub-spec 2.0 (the revision that carries state). Lifespan versions independently of http/websocket, so a lifespan scope must not borrow the http spec_version.
fn AsgiVersion::at_least( self : AsgiVersion, major~ : Int32, minor~ : Int32) -> Bool
Strict spec_version negotiation: is this scope's sub-spec at least major.minor? Parses the dotted spec_version (non-digit runs count as 0) and compares numerically, so "2.4".at_least(major=2, minor=3) is true. An application guards a version-gated feature with this instead of string equality.
struct TlsExtension
The tls extension's per-connection data (ASGI TLS extension). Present in a scope's extensions only when the connection is TLS-terminated by the server. Certificates are PEM text; tls_version / cipher_suite are the numeric IANA identifiers, None when the server does not expose them.
struct Extensions
The scope's extensions map, modelled as typed capability flags plus the one extension that carries data (tls). A server sets a flag to advertise that it will honour the matching outbound event — an application checks the flag before emitting HttpResponsePush / HttpResponsePathSend / HttpResponseEarlyHint / HttpResponseTrailers / WebSocketHttpResponseStart, exactly as an ASGI app tests "http.response.push" in scope["extensions"].
fn Extensions::none() -> Extensions
No extensions advertised: every capability off, no TLS data. The starting point servers and tests build from with the enable_* / with_tls helpers.
fn Extensions::enable( self : Extensions, name : String, value~ : Json = Json::object(Map([]))) -> Extensions
Advertise an extension this seam does not name, with whatever value the server wants to hand the application (the spec's own http.fullflush example carries an empty object).
fn Extensions::get(self : Extensions, name : String) -> Json?
What the server advertised for name, or None if it did not. Only the extensions this seam has no field for; the named ones are the Bools above.
fn Extensions::enable_push(self : Extensions) -> Extensions
Advertise the server-push extension (http.response.push).
fn Extensions::enable_trailers(self : Extensions) -> Extensions
Advertise the response-trailers extension (http.response.trailers).
fn Extensions::enable_pathsend(self : Extensions) -> Extensions
Advertise the path-send extension (http.response.pathsend).
fn Extensions::enable_zerocopysend(self : Extensions) -> Extensions
Advertise the zero-copy-send extension (http.response.zerocopysend): the server will send the contents of an open file descriptor with zero copies.
fn Extensions::enable_debug(self : Extensions) -> Extensions
Advertise the debug extension (http.response.debug). Per the ASGI spec this is for testing only and production servers should not implement it.
fn Extensions::enable_early_hint(self : Extensions) -> Extensions
Advertise the early-hints extension (http.response.early_hint): the server will forward HttpResponseEarlyHint messages as 103 Early Hints informational responses ahead of the final status.
fn Extensions::enable_websocket_http_response( self : Extensions) -> Extensions
Advertise the WebSocket-denial-with-response extension (websocket.http.response).
fn Extensions::with_tls( self : Extensions, tls : TlsExtension) -> Extensions
Attach tls extension data to a connection scope.
struct HttpScope
HTTP connection scope (ASGI type == "http"). raw_path / query_string stay Bytes because they are not guaranteed valid UTF-8; header names follow ASGI's lowercased-latin1 convention. asgi carries the version handshake, extensions the advertised server capabilities, and state is per-connection scratch copied from the lifespan state.
fn HttpScope::new( http_method~ : String, path~ : String, http_version~ : String = "1.1", scheme~ : String = "http", raw_path? : Bytes, query_string~ : Bytes = b"", root_path~ : String = "", headers~ : Array[(String, String)] = [], client? : (String, Int32), server? : (String, Int32?), asgi~ : AsgiVersion = AsgiVersion::default(), extensions~ : Extensions = Extensions::none(), state~ : Map[String, Json] = Map([])) -> HttpScope
Build an HttpScope with ASGI's usual defaults filled in — http_version "1.1", scheme "http", empty query/headers/state, the default asgi handshake, and no extensions. raw_path defaults to the UTF-8 encoding of path. The ergonomic constructor servers and the TestClient build scopes through, so callers spell only what differs from the common case.
struct WebSocketScope
WebSocket connection scope (ASGI type == "websocket"). scheme is "ws"/"wss"; subprotocols are the client-offered values. Carries the same asgi handshake and extensions capabilities as an http scope.
fn WebSocketScope::new( path~ : String, http_version~ : String = "1.1", scheme~ : String = "ws", raw_path? : Bytes, query_string~ : Bytes = b"", root_path~ : String = "", headers~ : Array[(String, String)] = [], client? : (String, Int32), server? : (String, Int32?), subprotocols~ : Array[String] = [], asgi~ : AsgiVersion = AsgiVersion::default(), extensions~ : Extensions = Extensions::none(), state~ : Map[String, Json] = Map([])) -> WebSocketScope
Build a WebSocketScope with ASGI's usual defaults filled in — scheme "ws", empty query/headers/subprotocols/state, the default asgi handshake, and no extensions. raw_path defaults to the UTF-8 encoding of path.
struct LifespanScope
Lifespan scope (ASGI type == "lifespan"): a single run spanning process startup and shutdown, whose state seeds every request scope. Carries its own asgi handshake — lifespan versions independently (sub-spec 2.0), so it does not borrow the http/websocket spec_version.
fn LifespanScope::new( asgi~ : AsgiVersion = AsgiVersion::lifespan(), state~ : Map[String, Json] = Map([])) -> LifespanScope
Build a LifespanScope with the lifespan asgi handshake (sub-spec 2.0) and an empty state by default.
enum Event
A protocol event flowing between server and application. Replaces ASGI's stringly-typed message dicts with a typed sum covering the http / websocket / lifespan message sets in both directions, including the standard extension messages (server push, path-send, zero-copy send, response trailers, early hints, debug, and WebSocket denial with a full HTTP response).
type Receive = async () -> Event
Pull the next inbound event. The async awaitable an application calls to read request body chunks, websocket frames, or lifespan signals.
type Send = async (Event) -> Unit
Push an outbound event. The async awaitable an application calls to emit response start/body, websocket frames, or lifespan completion.
type AsgiApp = async (Scope, Receive, Send) -> Unit
The load-bearing ASGI callable: (scope, receive, send). Every server binds to this shape and every framework in the suite ultimately compiles down to it.
fn WebSocketScope::select_subprotocol( self : WebSocketScope, supported : Array[String]) -> String?
Choose the subprotocol to accept from those the client offered (RFC 6455 §4.1): the first of the client's offered subprotocols, in the client's own order of preference, that the server also lists in supported; None when the two share none (the connection then proceeds with no subprotocol). The result is what the app hands to WebSocketAccept(subprotocol=..).
extend TlsExtension with Eq::
extend Event with Eq::
extend Event with Debug::
extend AsgiVersion with Eq::
extend Extensions with Eq::
§HTTP/2 pseudo-header lowering
HttpScope::from_h2_headers lowers an HTTP/2 (RFC 7540) or HTTP/3 HEADERS block into a scope the way a conforming server does: pseudo-headers consumed into the typed fields, host synthesised from :authority, and a malformed set rejected with the exact Http2HeaderError.
enum Http2HeaderError
Why a HEADERS frame does not map onto an HttpScope field-by-field: an HTTP/2 (RFC 7540 §8.1.2.3) or HTTP/3 request carries its request line as four *pseudo-headers* — :method, :scheme, :authority, :path — interleaved with the ordinary fields, and ASGI reserves no slot for them in scope["headers"]. A conforming server must consume the pseudo-headers into the scope's typed fields and hand the application only the ordinary headers, with host synthesised from :authority. moonasgi owns that lowering so every h2/h2c/h3 transport in the suite (mooncat) produces the same scope from the same frame, and an app never sees a :-prefixed header. A malformed pseudo-header set — a missing required one, a duplicate, an unknown :foo, or a pseudo-header after an ordinary field — is rejected with the exact Http2HeaderError, matching RFC 7540's "malformed request" treatment, so the transport can answer RST_STREAM(PROTOCOL_ERROR) instead of forwarding a bad scope.
fn percent_decode(target : String) -> Bytes
Percent-decode a request target's path into the characters ASGI's path carries, leaving the undecoded bytes for raw_path. %2F inside a segment therefore reaches the application as a slash in path, which is why a router that cares about segment boundaries reads raw_path. A stray % or a truncated escape is passed through as written rather than raising: the target is attacker-controlled, and a server that refuses to build a scope cannot answer 400 either.
fn HttpScope::from_h2_headers( headers : Array[(String, String)], http_version~ : String = "2", root_path~ : String = "", client? : (String, Int32), server? : (String, Int32?), extensions~ : Extensions = Extensions::none(), asgi~ : AsgiVersion = AsgiVersion::http(), state~ : Map[String, Json] = Map([])) -> Result[HttpScope, Http2HeaderError]
Lower an HTTP/2 (or HTTP/3) request's HEADERS block — pseudo-headers and ordinary fields in wire order — into an HttpScope, the way a conforming ASGI server consumes a frame. :method, :scheme and :path become the scope's http_method / scheme / path (with the query string split off :path); the returned headers are the ordinary fields only, with host synthesised at the front from :authority (replacing any host the peer also sent, per RFC 7540 §8.1.2.3). http_version defaults to "2"; pass "3" for an HTTP/3 transport, which shares this pseudo-header contract. The remaining scope fields (root_path, client, server, extensions, asgi, state) are supplied by the server exactly as for HttpScope::new. Returns Err naming the first violation for a malformed pseudo-header set, so the SEAM never hands a framework a scope with a :-prefixed header or a missing request-line field.
extend Http2HeaderError with Eq::
§Ergonomic sugar
The Request / Response / Handler / Middleware layer the suite lifts onto AsgiApp at the server boundary, plus the StreamingResponse, the scope-aware run_http_scoped a framework binds to, and the run_http / run_http_stream synchronous drivers.
struct Request
An inbound HTTP request in ergonomic form: the request line, headers, and the fully-read body. The sugar over @spec.HttpScope plus a drained Receive, so a Handler never touches the async transport directly.
struct Response
An outbound HTTP response: status, headers, and the full body. Mutable so middleware can decorate it before the server serialises it into HttpResponseStart + HttpResponseBody.
struct StreamingResponse
A streamed outbound response: the status line, headers, an ordered list of body chunks each emitted as its own HttpResponseBody, and optional trailing trailers. Models ASGI response streaming (multiple body messages with more_body: true) and the http.response.trailers extension without an async transport. events lowers it to the exact event sequence a server would send. early_hints (the http.response.early_hint extension) are the 103 Early Hints messages emitted ahead of the final response, each element a list of Link header values.
fn StreamingResponse::new( chunks~ : Array[Bytes], status~ : Int32 = 200, headers~ : Array[(String, String)] = [], trailers~ : Array[(String, String)] = [], early_hints~ : Array[Array[String]] = []) -> StreamingResponse
Build a StreamingResponse. status defaults to 200, headers, trailers and early_hints to empty; chunks is the ordered body, each element becoming one HttpResponseBody frame.
fn StreamingResponse::events( self : StreamingResponse) -> Array[@spec.Event]
Lower a streamed response to the outbound events a server sends: one HttpResponseEarlyHint per early-hint message (before the response starts), then an HttpResponseStart (with trailers set when any are present), then one HttpResponseBody per chunk — more_body: true on all but the last — and, if there are trailers, a terminating HttpResponseTrailers. An empty chunks still yields a single empty final body, so the stream is always well-formed.
type Handler = (Request) -> Response
The ergonomic request→response function most handlers are written as. The synchronous sugar the suite lifts onto @spec.AsgiApp at the server boundary.
type StreamHandler = (Request) -> StreamingResponse
A streaming handler: produces a StreamingResponse (multi-chunk body, optional trailers) instead of a single buffered Response. Driven by run_http_stream and the TestClient.
type Middleware = (Handler) -> Handler
A handler transformer that wraps a downstream Handler to add cross-cutting behaviour. Composed as an onion where the first registered is outermost.
fn Response::new( status : Int32, headers : Array[(String, String)], body : Bytes) -> Response
Build a response from raw bytes.
fn Request::header(self : Request, name : String) -> String?
Look up the first header matching name, following ASGI's lowercased-name convention.
fn Response::header(self : Response, name : String) -> String?
Look up the first response header matching name, same convention as Request::header.
fn compose(middlewares : Array[Middleware], base : Handler) -> Handler
Compose middlewares over a base handler. The first element is the outermost wrapper, matching registration order.
fn Response::text(status~ : Int32 = 200, body : String) -> Response
A text/plain; charset=utf-8 response whose body is the UTF-8 encoding of body. The ergonomic constructor for the common string reply.
fn Response::json(status~ : Int32 = 200, value : Json) -> Response
An application/json response whose body is the UTF-8 encoding of value serialised with Json::stringify. The ergonomic constructor for a JSON reply.
enum Drain
Why a body drain stopped: the request body finished, the client went away mid-request, or the stream ran out without either. A long-polling or streaming handler cares about the difference — Disconnected means nothing it sends will arrive, so cleanup is all that is left to do.
fn Response::events(self : Response) -> Array[@spec.Event]
Serialise a Response into the outbound event pair a server sends: an HttpResponseStart carrying status and headers (no trailers), then a single HttpResponseBody with the whole body and more_body: false. The public Response::events for callers that want the lowered form directly.
fn run_http_scoped( app : (@spec.HttpScope, Bytes) -> Array[@spec.Event], scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
The scope-aware sans-transport core: drain the http request body from inbound (accumulating HttpRequest chunks until more_body is false) and hand app the full @spec.HttpScope alongside the assembled body, returning the outbound events app emits. This is the seam a framework binds to — a framework reads what the ergonomic Request drops: root_path (for mounted sub-apps), extensions (to gate a feature on what the server advertises), client/server peers, the asgi handshake, and state (the map a lifespan startup seeds and the server copies onto every request scope). It is the synchronous analog of the ASGI (scope, receive, send) callable for the common drain-then-handle shape; run_http_app is the Request-level wrapper over it. Non-http scopes yield [].
fn run_http_scoped_drain( app : (@spec.HttpScope, Bytes, Drain) -> Array[@spec.Event], scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
run_http_scoped, but the app also learns why the body drain stopped. A handler that holds a connection open — long polling, a slow upload — needs to tell "the client sent everything" from "the client went away", which the plain form cannot: it runs the handler to completion either way and the response goes nowhere. Non-http scopes yield [].
fn run_http_app( app : (Request) -> Array[@spec.Event], scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
The Request-level sans-transport core: drain the http request body from inbound, hand the assembled Request to app, and return the outbound events app emits. The ergonomic wrapper over run_http_scoped for apps that only need the request line, headers, and body — every server and the TestClient drive a Handler through it. Non-http scopes yield [].
fn run_http( handler : Handler, scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
The synchronous counterpart of to_asgi: drive a Handler over an already materialised inbound event sequence and return the outbound events a server would send — [HttpResponseStart, HttpResponseBody] for an http scope, [] otherwise. A thin run_http_app wrapper over Response::events, the sans-transport core to_asgi mirrors with async receive/send.
fn run_http_stream( handler : StreamHandler, scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
Drive a streaming StreamHandler over an inbound event sequence, returning the full outbound stream — HttpResponseStart, one HttpResponseBody per chunk (more_body: true on all but the last), and a trailing HttpResponseTrailers when the response carries trailers. The streaming counterpart of run_http.
fn to_asgi(handler : Handler) -> @spec.AsgiApp
Lift a synchronous Handler onto the load-bearing @spec.AsgiApp the server binds to. For an http scope it drains the request body — looping receive() and accumulating HttpRequest chunks until more_body is false — assembles a Request, runs the handler, then emits HttpResponseStart followed by a single HttpResponseBody. Non-http scopes (websocket, lifespan) are no-ops: this sugar covers request→response handlers only. Shares its drain, request assembly, and response serialisation with run_http, which tests the same logic without the async transport.
extend Request with Eq::
extend Response with Eq::
extend Drain with Debug::
extend StreamingResponse with Eq::
extend Drain with Eq::
§Lifespan core
The synchronous lifespan driver mirroring run_http: a startup / shutdown LifespanHandler that seeds scope.state in place, driven in-process by run_lifespan, with the failed-startup short-circuit ASGI pins.
enum LifespanReply
An application's answer to a lifespan phase: Complete when startup or shutdown succeeded, or Failed with a message the server logs and (for startup) aborts the boot on. Mirrors the two replies ASGI allows to each lifespan message — lifespan.startup.complete / .failed and lifespan.shutdown.complete / .failed.
struct LifespanHandler
A synchronous lifespan application in the startup / shutdown shape, the lifespan analog of the http Handler and the WebSocket WebSocketHandler. on_startup runs once at boot: it seeds the scope's state in place (the map the server then copies onto every request scope — a DB pool handle, a loaded config) and returns Complete or Failed. on_shutdown runs once at teardown to release those resources. Driven in-process by run_lifespan, so boot/teardown logic is testable on every backend without an async runtime — the faithful synchronous core the async server (mooncat) lifts onto the lifespan protocol.
fn LifespanHandler::new( on_startup~ : (@spec.LifespanScope) -> LifespanReply = fn(_s)
Build a LifespanHandler. Both phases default to succeeding without doing anything, so a caller overrides only the phase it needs — an app that just wants a startup hook leaves on_shutdown alone.
fn run_lifespan( handler : LifespanHandler, scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
Drive a LifespanHandler over a materialised inbound event stream, folding it into the outbound replies a server would send. LifespanStartup runs on_startup and emits LifespanStartupComplete or LifespanStartupFailed; a failed startup ends the run, since ASGI has the server abort the boot and never send lifespan.shutdown. LifespanShutdown runs on_shutdown and emits the matching shutdown reply. The handler mutates scope.state in place during startup, exactly as an ASGI app populates scope["state"]. This is the synchronous lifespan core mirroring run_http / ws_run; a non-lifespan scope yields [].
extend LifespanReply with Eq::
§WebSocket core
The synchronous WebSocket core mirroring run_http: a connect / receive / disconnect WebSocketHandler (accept, subprotocols, echo, close, deny-with-HTTP) driven in-process by ws_run / ws_run_app on every backend.
enum WsMessage
A message crossing a WebSocket in either direction: a UTF-8 Text frame or a Binary frame. The ergonomic form of WebSocketReceive / WebSocketSendText / WebSocketSendBytes, so a WebSocketHandler reasons over messages instead of raw events.
enum WsAccept
The application's answer to a WebSocketConnect, mirroring the three replies ASGI allows to an opening handshake: Accept it (optionally choosing a subprotocol and adding response headers), Reject it with a bare close code (ASGI closes the handshake with websocket.close), or DenyHttp it with a full HTTP response (the websocket.http.response extension — a real status + headers + body instead of a bare close).
enum WsSend
An outbound action a WebSocketHandler takes while the connection is open: send a SendText / SendBinary frame, or Close the connection with a code and reason. A Close ends the handler's send stream — the driver stops pulling further inbound messages, mirroring a server that has closed the socket.
struct WebSocketHandler
A synchronous WebSocket application in the connect / receive / disconnect shape, the WebSocket analog of the http Handler. on_connect decides the handshake from the scope; on_receive maps each inbound WsMessage to the frames to send back (it may Close); on_disconnect runs when the client goes away, carrying the close code and the optional close reason (ASGI 2.5). Driven in-process by ws_run and the TestClient, so accept/subprotocol/echo/close logic is testable on every backend without an async socket — the faithful synchronous core the async server (mooncat) lifts onto AsgiApp.
fn WebSocketHandler::new( on_connect~ : (@spec.WebSocketScope) -> WsAccept = fn(_s)
Build a WebSocketHandler. on_connect defaults to accepting the handshake with no chosen subprotocol and no extra headers; on_receive and on_disconnect default to doing nothing. Callers override only the phases they care about.
fn WebSocketHandler::echo(subprotocol? : String) -> WebSocketHandler
An echo handler: accept the handshake (optionally negotiating subprotocol), then send every received message straight back — text as text, binary as binary. The canonical WebSocket smoke test.
fn ws_run( handler : WebSocketHandler, scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
Drive a WebSocketHandler over an inbound event sequence, returning the outbound events a server would send. The WebSocket counterpart of run_http: a WebSocket scope is driven through drive_ws; any other scope yields [], since this sugar covers websocket connections only.
fn ws_run_app( app : (@spec.WebSocketScope, Array[@spec.Event]) -> Array[@spec.Event], scope : @spec.Scope, inbound : Array[@spec.Event]) -> Array[@spec.Event]
The general sans-transport WebSocket core: hand the whole inbound event stream and the @spec.WebSocketScope to app and return the outbound events it emits. The escape hatch under ws_run for applications whose control flow is not the connect/receive/disconnect fold — an app is free to inspect every scope field (subprotocols, headers, extensions, state) and emit any sequence. Non-websocket scopes yield []. The WebSocket analog of run_http_app.
extend WsAccept with Eq::
extend WsMessage with Eq::
extend WsMessage with Debug::
extend WsSend with Eq::
§TestClient
The in-process application driver built on the synchronous core: send a synthetic request (or drive a WebSocket connection), capture the reassembled response (body, trailers, pushes, pathsend, early hints) or WsTestSession — no socket, testable on every backend.
struct PushPromise
A server-side push promise captured by the TestClient: the pushed path and the request headers the server would send for it (http.response.push).
fn PushPromise::header(self : PushPromise, name : String) -> String?
First push-request header matching name.
struct ZeroCopySend
A http.response.zerocopysend message captured by the TestClient: the open file descriptor the app handed off, and the optional byte offset / count. The sans-transport analog of the server performing the zero-copy send.
struct TestResponse
The materialised result of driving an application in-process: the response status and headers, the body reassembled from every HttpResponseBody chunk, the trailing headers gathered from HttpResponseTrailers, any push promises, the pathsend path if the app used the path-send extension, the last zerocopysend handoff, every debug info payload, and the early_hints (each a 103 message's Link values). The captured, sans-transport analog of what a real client would observe.
fn TestResponse::header(self : TestResponse, name : String) -> String?
First response header matching name (ASGI lowercased-name convention).
fn TestResponse::trailer(self : TestResponse, name : String) -> String?
First trailing header matching name.
fn TestResponse::text(self : TestResponse) -> String
The response body decoded as UTF-8 (lossy: invalid sequences become the replacement character), for asserting on text responses.
fn TestResponse::json(self : TestResponse) -> Json raise
The response body parsed as JSON. Raises @json.ParseError if the body is not valid JSON, mirroring a real client's .json().
fn reassemble(events : Array[@spec.Event]) -> TestResponse
Fold an outbound event stream into a TestResponse: HttpResponseStart sets status/headers, every HttpResponseBody appends to the body, every HttpResponseTrailers appends to the trailers, each HttpResponsePush records a promise, HttpResponsePathSend records the served path, and every HttpResponseEarlyHint records one 103 message's Link values. Other events (websocket, lifespan) are ignored — the http response is what a client observes. 把一串事件拼回一个响应,一致性套件与测试客户端共用。
struct TestClient
An in-process application driver — the ASGI TestClient — built on the synchronous @http.run_http_app core, so it needs no socket and runs on every backend. It builds a synthetic http scope, feeds the request body in as HttpRequest events, runs the application, and reassembles the outbound stream into a TestResponse. app is the event-emitting application; the new / from_stream constructors adapt a @http.Handler / @http.StreamHandler.
fn TestClient::from_app( app : (@http.Request) -> Array[@spec.Event], root_path~ : String = "", base_headers~ : Array[(String, String)] = [], client~ : (String, Int32)? = Some(("testclient", 50000)), server~ : (String, Int32?)? = Some(("testserver", Some(80))), extensions~ : @spec.Extensions = @spec.Extensions::none()) -> TestClient
Build a TestClient from a raw event-emitting application (the shape @http.run_http_app drives). Defaults mirror a typical test harness: empty root_path, no base headers, a ("testclient", 50000) client peer, a ("testserver", 80) server, and no advertised extensions.
fn TestClient::new( handler : @http.Handler, root_path~ : String = "", base_headers~ : Array[(String, String)] = [], client~ : (String, Int32)? = Some(("testclient", 50000)), server~ : (String, Int32?)? = Some(("testserver", Some(80))), extensions~ : @spec.Extensions = @spec.Extensions::none()) -> TestClient
Build a TestClient from a unary @http.Handler.
fn TestClient::from_stream( handler : @http.StreamHandler, root_path~ : String = "", base_headers~ : Array[(String, String)] = [], client~ : (String, Int32)? = Some(("testclient", 50000)), server~ : (String, Int32?)? = Some(("testserver", Some(80))), extensions~ : @spec.Extensions = @spec.Extensions::none()) -> TestClient
Build a TestClient from a streaming @http.StreamHandler (multi-chunk body, optional trailers).
fn request_events( body : Bytes, chunks? : Array[Bytes]) -> Array[@spec.Event]
Materialise a request body into inbound HttpRequest events. When chunks is given each element becomes its own event (more_body: true on all but the last), exercising a streamed request; otherwise body is a single event.
fn TestClient::request( self : TestClient, http_method~ : String, path~ : String, headers~ : Array[(String, String)] = [], body~ : Bytes = b"", http_version~ : String = "1.1", chunks? : Array[Bytes]) -> TestResponse
Drive one request through the application and capture the response. Splits path into path + query string, merges the client's base_headers before the per-request headers, builds an http scope (carrying the client's root_path / peers / advertised extensions), feeds the body in, runs the app, and reassembles the outbound stream. Pass chunks to stream the request body as several HttpRequest events.
fn TestClient::get( self : TestClient, path : String, headers~ : Array[(String, String)] = []) -> TestResponse
GET path.
fn TestClient::head( self : TestClient, path : String, headers~ : Array[(String, String)] = []) -> TestResponse
HEAD path.
fn TestClient::delete( self : TestClient, path : String, headers~ : Array[(String, String)] = []) -> TestResponse
DELETE path.
fn TestClient::post( self : TestClient, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> TestResponse
POST path with body.
fn TestClient::put( self : TestClient, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> TestResponse
PUT path with body.
fn TestClient::patch( self : TestClient, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> TestResponse
PATCH path with body.
struct WsTestSession
The materialised result of driving a WebSocket connection in-process: whether the handshake was accepted, the negotiated subprotocol and accept_headers, every server→client message (messages), whether the server closed and with what close_code / close_reason, and — when the handshake was denied with the websocket.http.response extension — the captured HTTP denial response. The WebSocket analog of TestResponse.
fn WsTestSession::header(self : WsTestSession, name : String) -> String?
First accept-response header matching name.
fn WsTestSession::texts(self : WsTestSession) -> Array[String]
The server→client messages that were text frames, decoded to their strings (binary frames are skipped). The ergonomic assertion target for an echo/chat exchange.
fn TestClient::websocket( self : TestClient, path~ : String, handler~ : @ws.WebSocketHandler, send~ : Array[@ws.WsMessage] = [], headers~ : Array[(String, String)] = [], subprotocols~ : Array[String] = [], disconnect~ : Int32? = Some(1000)) -> WsTestSession
Drive a @ws.WebSocketHandler through a full connection in-process and capture the result. Opens a WebSocketConnect, feeds each send message in as a WebSocketReceive, then (unless disconnect is None) a WebSocketDisconnect with the given close code, runs the handler through the synchronous @ws.ws_run core, and reassembles the server's outbound frames into a WsTestSession. No socket — testable on every backend, the WebSocket counterpart of request.
fn TestClient::websocket_app( self : TestClient, path~ : String, app~ : (@spec.WebSocketScope, Array[@spec.Event]) -> Array[@spec.Event], send~ : Array[@ws.WsMessage] = [], headers~ : Array[(String, String)] = [], subprotocols~ : Array[String] = [], disconnect~ : Int32? = Some(1000)) -> WsTestSession
Drive a general (@spec.WebSocketScope, Array[@spec.Event]) -> Array[@spec.Event] application through a full connection in-process. Like websocket, but for an app that consumes the whole inbound stream directly (the @ws.ws_run_app escape hatch) rather than the connect/receive/disconnect fold.
extend PushPromise with Eq::
extend PushPromise with Debug::
extend ZeroCopySend with Eq::
extend ZeroCopySend with Debug::
extend TestResponse with Eq::
extend TestResponse with Debug::
extend WsTestSession with Eq::
extend WsTestSession with Debug::
§Conformance harness
A table-driven self-test that drives every Event variant and every Scope field through run_http / ws_run / TestClient and asserts round-trip fidelity — reusable as run_conformance() to self-verify the seam wiring.
struct ConformanceReport
The outcome of the ASGI 3.0 conformance harness: how many round-trip checks ran, how many passed, and the names of any that failures. A clean run has an empty failures. Reusable beyond the test suite — a server or framework built on the seam can call run_conformance to self-verify the wiring.
fn ConformanceReport::ok(self : ConformanceReport) -> Bool
Whether every conformance check passed (no failures recorded).
fn run_conformance() -> ConformanceReport
Run the ASGI 3.0 conformance harness: a table-driven battery that drives every @spec.Event variant and every Scope field through @http.run_http, ws_run, and the @client.TestClient, asserting round-trip fidelity — a value put in comes back unchanged, and no two distinct events or scope shapes are confused — and a negative table that asserts validate_events rejects every malformed ordering (a body before the response starts, a websocket frame before the handshake, a lifespan shutdown reply before startup, and the rest) with the exact violation. Returns a ConformanceReport naming any check that failed, so the suite (and any downstream server) can assert ok().
extend ConformanceReport with Eq::
§Event-ordering validation
The ASGI ordering rules as code: validate_events walks an outbound stream and names the first violation (a body before the response starts, a frame before the handshake, and the rest), so a server can reject a buggy app.
enum EventOrderError
Why an outbound event stream is malformed. ASGI pins the order an application may emit messages in — a body before the response starts, a second start, a websocket frame before the handshake is accepted, and so on are all protocol violations. validate_events walks a stream and names the first violation it finds; a well-formed stream returns None. A server can run this over its own emissions to catch a buggy app early, and the conformance harness drives a negative table through it.
fn EventOrderError::describe(self : EventOrderError) -> String
A one-line human description of the violation, used in conformance failure names and server logs.
fn validate_http_response(events : Array[@spec.Event]) -> EventOrderError?
Validate the outbound event stream an application emits for an http request, against ASGI's http response ordering: at most one HttpResponseStart, no body or pathsend/zerocopysend before it, early hints only ahead of it, a body stream that terminates once (more_body: false), trailers only when the start promised them and only after the body completes, and nothing after the response is fully sent. HttpResponseDebug must come once and before the start; HttpResponsePush only after it; HttpResponsePathSend cannot be mixed with a body. Returns the first violation, or None for a well-formed complete response.
fn validate_ws_response(events : Array[@spec.Event]) -> EventOrderError?
Validate the outbound event stream an application emits for a websocket connection, against ASGI's handshake ordering: the connect must be answered with an WebSocketAccept, a WebSocketClose, or a denial (WebSocketHttpResponseStart + body); frames are only legal once accepted; nothing follows a close or a completed denial; a denial cannot mix with an accept. Returns the first violation, or None for a well-formed stream.
fn validate_lifespan_replies( events : Array[@spec.Event]) -> EventOrderError?
Validate the outbound replies an application emits for a lifespan run: a startup reply (LifespanStartupComplete / LifespanStartupFailed) before a shutdown reply (LifespanShutdownComplete / LifespanShutdownFailed), each phase answered at most once. Returns the first violation, or None.
fn validate_events( scope : @spec.Scope, events : Array[@spec.Event]) -> EventOrderError?
Validate an outbound event stream against the ordering rules for its scope, dispatching to the http / websocket / lifespan validator. The public entry a server calls to check an application's emissions before writing them to the wire.
fn validate_inbound(event : @spec.Event) -> EventOrderError?
Check an inbound event an application is about to act on. ASGI requires exactly one of bytes or text to be set on a websocket.receive, and a frame that sets neither, or both, is a server bug the application should not have to guess about. Returns None for a well-formed event.
extend EventOrderError with Eq::
§Headers
The header representation both sides of the seam share: case-insensitive lookup over the raw byte pairs ASGI carries, without copying them into a map that would lose duplicates.
fn latin1_decode(bytes : Bytes) -> String
Decode a raw byte string to a latin-1 String: each byte becomes the character with that code point. Total — every byte 0x00..0xFF is a valid code point.
fn latin1_encode(s : String) -> Bytes
Encode a String back to a header byte string. The inverse of latin1_decode for anything that came from the wire, since HTTP header values are latin-1 by the protocol's own rules. A character above U+00FF never came off a wire — it was put there by a framework setting, say, a content-disposition filename — and truncating it to its low byte would silently corrupt it. Those are written as their UTF-8 bytes instead, which is what real servers emit and what RFC 6266 §4.3 expects a recipient to decode.
fn headers_from_wire( wire : Array[(Bytes, Bytes)]) -> Array[(String, String)]
Decode a wire header list (raw ASGI byte-string pairs, as they arrive off an HTTP/1.1 or HPACK frame) into the (String, String) pairs the seam uses.
fn headers_to_wire( headers : Array[(String, String)]) -> Array[(Bytes, Bytes)]
Encode the seam's header list back to raw ASGI byte-string pairs for the wire. The exact inverse of headers_from_wire, byte for byte.
fn split_target(target : String) -> (String, String)
Split a request target into its path and raw query string on the first ?; "/a/b?x=1&y=2" → ("/a/b", "x=1&y=2"), "/a/b" → ("/a/b", "").
§ASGI 2.0 applications
The legacy double-callable convention a 3.0 server is encouraged to keep running. MoonBit has no signature reflection, so guarantee_single_callable takes the convention as an explicit constructor, AsgiApplication, instead of detecting it.
type AsgiAppInstance = async (Receive, Send) -> Unit
The asynchronous instance a legacy app's first callable returns: already bound to the scope, it drives the connection with receive / send.
type LegacyAsgiApp = (Scope) -> AsgiAppInstance
A legacy ASGI 2.0 double-callable application: a synchronous application(scope) that returns the AsgiAppInstance.
enum AsgiApplication
An application in either calling convention — the current 3.0 single-callable form or the legacy 2.0 double-callable form. A server holds one of these and normalizes it with guarantee_single_callable; the enum is the typed, reflection-free stand-in for asgiref detecting the convention at runtime.
fn double_to_single_callable(app : LegacyAsgiApp) -> AsgiApp
Wrap a legacy 2.0 double-callable app as a 3.0 single-callable AsgiApp (asgiref's double_to_single_callable): call the first callable with the scope to get the instance, then drive it with receive / send.
fn guarantee_single_callable(app : AsgiApplication) -> AsgiApp
Normalize any application to a single-callable AsgiApp (asgiref's guarantee_single_callable): a 3.0 app passes straight through, a legacy 2.0 app is wrapped. A server calls this once and then only ever drives single-callables.
fn run_legacy( app : (Scope) -> (Array[Event]) -> Array[Event], scope : Scope, inbound : Array[Event]) -> Array[Event]
The synchronous core of the legacy two-call convention, testable without an async runtime the way run_http is for to_asgi: the first callable takes the scope and returns an instance that folds the inbound event stream into the outbound one. double_to_single_callable is the async lift of exactly this two-step shape, so a green run_legacy covers the convention's semantics that the async wrapper relies on.
§Middleware
Application-level middleware any framework's ASGI app can be wrapped in, as Starlette's: CORS, gzip, trusted hosts, the HTTPS redirect and the 500 for a failure. Each is a record implementing Layer; a caller's own middleware implements it too and stacks with them.
pub(open) trait LayerSomething that wraps one ASGI application in another: CORS, compression, a host check, or the caller's own. Every middleware here is a record that implements it, so a caller's middleware stacks with them on equal terms.
struct Wrap((@spec.AsgiApp) -> @spec.AsgiApp) ///| pub impl Layer for Wrap with fn wrap(self, app)
A middleware written as a plain function from application to application.
fn stack(layers : Array[&Layer], app : @spec.AsgiApp) -> @spec.AsgiApp
app inside layers, the first outermost: it sees the request first and the response last, the order Starlette's middleware=[...] gives.
extend Wrap with Layer::
struct Cors
Cross-origin resource sharing, as Starlette's CORSMiddleware does it (Fetch standard §3.2). origins, methods and headers are allow-lists, and "*" in one allows anything. A preflight is answered here: 200 with the negotiated headers, or 400 naming what was refused. Other cross-origin responses get access-control-allow-origin when the origin is allowed. With credentials the origin is echoed rather than *, as the standard requires.
extend Cors with Eq::
extend Cors with Debug::
let cors : Cors =
Starlette's defaults: no origin, GET only, the safelisted headers only, no credentials, preflights cached for ten minutes.
fn Cors::new( origins~ : Array[String] = cors.origins, methods~ : Array[String] = cors.methods, headers~ : Array[String] = cors.headers, credentials~ : Bool = cors.credentials, expose~ : Array[String] = cors.expose, max_age~ : Int32 = cors.max_age) -> Cors
impl Layer for Cors with fn wrap(self, app)
extend Cors with Layer::
struct Gzip
Gzip for responses a client accepts it for, as Starlette's GZipMiddleware: a body of at least minimum octets, sent in one message, not already content-encoded. level is the zlib effort, 0 to 9. A body sent in several messages is passed through as it comes: compressing it would hold it back until the end, and a stream is sent in pieces so that it is not held back. An event stream is never compressed, for the same reason.
extend Gzip with Eq::
extend Gzip with Debug::
let gzip : Gzip =
Starlette's defaults: 500 octets and up, the most effort.
fn Gzip::new( minimum~ : Int32 = gzip.minimum, level~ : Int32 = gzip.level) -> Gzip
impl Layer for Gzip with fn wrap(self, app)
extend Gzip with Layer::
struct TrustedHosts
Only the hosts listed may be asked for, as Starlette's TrustedHostMiddleware: a host header naming another answers 400. *.example.com allows any subdomain, * alone allows any host. With www_redirect, a request for example.com where only www.example.com is listed is sent there.
extend TrustedHosts with Eq::
extend TrustedHosts with Debug::
let trusted_hosts : TrustedHosts =
Starlette's defaults: any host, and the redirect to www. on.
fn TrustedHosts::new( hosts~ : Array[String] = trusted_hosts.hosts, www_redirect~ : Bool = trusted_hosts.www_redirect) -> TrustedHosts
impl Layer for TrustedHosts with fn wrap(self, app)
struct HttpsRedirect
Send every plain request to its secure equivalent, as Starlette's HTTPSRedirectMiddleware: http to https and ws to wss, with a 307 so the method and body are kept. Port 80 or 443 in the host is dropped; any other port stays.
extend HttpsRedirect with Eq::
extend HttpsRedirect with Debug::
let https_redirect : HttpsRedirect = HttpsRedirect::
fn HttpsRedirect::new() -> HttpsRedirect
impl Layer for HttpsRedirect with fn wrap(_self, app)
extend TrustedHosts with Layer::
extend HttpsRedirect with Layer::
struct ServerErrors
What an application's failure becomes, as Starlette's ServerErrorMiddleware: a 500 when the response has not started, then the error raised on to the server, which logs it. debug puts the error in the body. handler writes the answer instead: (status, headers, body) for the error.
let server_errors : ServerErrors =
Starlette's defaults: no detail in the body, the plain 500.
fn ServerErrors::new( debug~ : Bool = server_errors.debug, handler? : (Error) -> (Int32, Array[(String, String)], String)) -> ServerErrors
impl Layer for ServerErrors with fn wrap(self, app)
extend ServerErrors with Layer::
§Async test driver
A real AsgiApp run in-process on moonbitlang/async, as asgiref's testing and Starlette's TestClient: communicate steps through events one at a time, Client sends requests, opens WebSocket sessions and runs the lifespan around a test, and an error the application raises reaches the test.
let timeout : Int32 = 1000
How long receive and wait wait by default, in milliseconds: asgiref's one second.
let quiet : Int32 = 100
How long receive_nothing listens before it concludes nothing is coming, in milliseconds: asgiref's 100 ms.
suberror RefusedWhat a test driver could not get from the application under test.
extend Refused with Eq::
struct Communicator
A running application under test, as asgiref's ApplicationCommunicator: events go in with send, come out with receive, and wait waits for the application to return. An error the application raises comes out of the next send, receive or wait, after the events it sent before failing. Made by communicate, which owns the application's task.
async fn[X] communicate( app : @spec.AsgiApp, scope : @spec.Scope, body : async (Communicator) -> X, timeout~ : Int32 = timeout) -> X
Start app on scope, hand body a Communicator to talk to it, and return what body returns. The application runs as a task of its own and is cancelled if it is still running when body returns, as asgiref cancels an application its test is done with. timeout is the default wait of receive and wait; each call can override it.
async fn Communicator::send( self : Communicator, event : @spec.Event) -> Unit
Hand event to the application's receive (asgiref's send_input). Raises the application's error if it has already failed.
async fn Communicator::receive( self : Communicator, timeout? : Int32) -> @spec.Event
The next event the application sent (asgiref's receive_output). Raises Timeout if none arrives within timeout milliseconds, Finished once the application has returned and everything it sent has been taken, and the application's own error if it failed.
async fn Communicator::receive_nothing( self : Communicator, timeout? : Int32) -> Bool
Whether the application stays silent for timeout milliseconds (asgiref's receive_nothing). An event that does arrive is left for the next receive. An application that returned is silent; one that failed raises its error.
async fn Communicator::wait(self : Communicator, timeout? : Int32) -> Unit
Wait for the application to return. Raises Timeout if it is still running after timeout milliseconds, and the application's error if it failed.
struct Client
An in-process client for a real AsgiApp, as Starlette's TestClient. Each request runs the application on a fresh scope, feeds it the body, sends http.disconnect once the response is complete, and waits for the application to return, so an error it raises fails the test. @client.TestClient is the synchronous counterpart for event-folding handlers. state is the lifespan state: lifespan hands it to the application's startup, and every request and WebSocket scope gets a shallow copy, as the lifespan sub-specification asks.
fn Client::new( app : @spec.AsgiApp, root_path~ : String = "", base_headers~ : Array[(String, String)] = [], client~ : (String, Int32)? = Some(("testclient", 50000)), server~ : (String, Int32?)? = Some(("testserver", Some(80))), extensions~ : @spec.Extensions = @spec.Extensions::none(), state~ : Map[String, Json] =
Build a Client for app. The peers default to Starlette's ("testclient", 50000) and ("testserver", 80); timeout is how long each wait for the application lasts.
async fn Client::request( self : Client, http_method~ : String, path~ : String, headers~ : Array[(String, String)] = [], body~ : Bytes = b"", http_version~ : String = "1.1", chunks? : Array[Bytes]) -> @client.TestResponse
Drive one request through the application and capture the response. path may carry a query string; the client's base_headers go before headers; chunks streams the body as several http.request events. Raises Finished if the application returns before completing the response, Timeout if it stalls, and the application's own error if it fails, before or after responding. An application that streams without end never completes; drive it with communicate.
async fn Client::get( self : Client, path : String, headers~ : Array[(String, String)] = []) -> @client.TestResponse
GET path.
async fn Client::head( self : Client, path : String, headers~ : Array[(String, String)] = []) -> @client.TestResponse
HEAD path.
async fn Client::delete( self : Client, path : String, headers~ : Array[(String, String)] = []) -> @client.TestResponse
DELETE path.
async fn Client::post( self : Client, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> @client.TestResponse
POST path with body.
async fn Client::put( self : Client, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> @client.TestResponse
PUT path with body.
async fn Client::patch( self : Client, path : String, body~ : Bytes = b"", headers~ : Array[(String, String)] = []) -> @client.TestResponse
PATCH path with body.
async fn[X] Client::websocket( self : Client, path~ : String, body : async (Session) -> X, headers~ : Array[(String, String)] = [], subprotocols~ : Array[String] = []) -> X
Open a WebSocket to path, run body with the accepted Session, then close the connection with 1000 unless body already did, and wait for the application to return. Raises Closed if the application closes before accepting, Denied with the response if it declines with websocket.http.response, and Unexpected for any other first reply.
async fn[X] Client::lifespan(self : Client, body : async () -> X) -> X
Run the application's lifespan startup, then body, then its shutdown, as entering and leaving Starlette's with TestClient(app). Startup sees this client's state, so what it stores there is in every scope body opens. Shutdown runs even if body fails. Raises Startup or Shutdown with the application's message when it reports a failure, and Unexpected for any other reply.
struct Session
An accepted WebSocket under test, as the session Starlette's websocket_connect yields: the negotiated subprotocol and accept headers, plus methods that talk to the application.
async fn Session::send(self : Session, message : @ws.WsMessage) -> Unit
Send message to the application as websocket.receive.
async fn Session::send_text(self : Session, text : String) -> Unit
Send a text message.
async fn Session::send_bytes(self : Session, bytes : Bytes) -> Unit
Send a binary message.
async fn Session::send_json(self : Session, value : Json) -> Unit
Send value serialised as a text message.
async fn Session::receive( self : Session, timeout? : Int32) -> @ws.WsMessage
The next message the application sent. Raises Closed when the application closes the connection, and Timeout, Finished or the application's error as Communicator::receive.
async fn Session::receive_text(self : Session, timeout? : Int32) -> String
The next message, which must be text.
async fn Session::receive_bytes(self : Session, timeout? : Int32) -> Bytes
The next message, which must be binary.
async fn Session::receive_json(self : Session, timeout? : Int32) -> Json
The next message parsed as JSON; text or binary, as Starlette's receive_json.
async fn Session::close( self : Session, status~ : Int32 = 1000, reason? : String) -> Unit
Tell the application the client went away with status (websocket.disconnect).