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.

CItestsGitHublicense
$moon add Lfan-ke/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
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
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
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
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
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
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
fn AsgiVersion::at_least( self : AsgiVersion, major~ : Int, minor~ : Int) -> 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
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
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
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
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
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
fn Extensions::enable_push(self : Extensions) -> Extensions

Advertise the server-push extension (http.response.push).

fn
fn Extensions::enable_trailers(self : Extensions) -> Extensions

Advertise the response-trailers extension (http.response.trailers).

fn
fn Extensions::enable_pathsend(self : Extensions) -> Extensions

Advertise the path-send extension (http.response.pathsend).

fn
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
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
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
fn Extensions::enable_websocket_http_response( self : Extensions) -> Extensions

Advertise the WebSocket-denial-with-response extension (websocket.http.response).

fn
fn Extensions::with_tls( self : Extensions, tls : TlsExtension) -> Extensions

Attach tls extension data to a connection scope.

struct
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
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, Int)? = None, server? : (String, Int?)? = None, 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
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
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, Int)? = None, server? : (String, Int?)? = None, 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
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
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
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
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
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
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.

§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
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
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
fn HttpScope::from_h2_headers( headers : Array[(String, String)], http_version? : String = "2", root_path? : String = "", client? : (String, Int)? = None, server? : (String, Int?)? = None, 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.

§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
struct Request

An inbound HTTP request in ergonomic form: the request line, headers, and the fully-read body. The sugar over HttpScope plus a drained Receive, so a Handler never touches the async transport directly.

struct
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
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
fn StreamingResponse::new( chunks~ : Array[Bytes], status? : Int = 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
fn StreamingResponse::events(self : StreamingResponse) -> Array[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
type Handler = (Request) -> Response

The ergonomic request→response function most handlers are written as. The synchronous sugar the suite lifts onto AsgiApp at the server boundary.

type
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
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
fn Response::new( status : Int, headers : Array[(String, String)], body : Bytes) -> Response

Build a response from raw bytes.

fn
fn Request::header(self : Request, name : String) -> String?

Look up the first header matching name, following ASGI's lowercased-name convention.

fn
fn Response::header(self : Response, name : String) -> String?

Look up the first response header matching name, same convention as Request::header.

fn
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
fn Response::text(status? : Int = 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
fn Response::json(status? : Int = 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
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
fn Response::events(self : Response) -> Array[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
fn run_http_scoped( app : (HttpScope, Bytes) -> Array[Event], scope : Scope, inbound : Array[Event]) -> Array[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 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
fn run_http_scoped_drain( app : (HttpScope, Bytes, Drain) -> Array[Event], scope : Scope, inbound : Array[Event]) -> Array[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
fn run_http_app( app : (Request) -> Array[Event], scope : Scope, inbound : Array[Event]) -> Array[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
fn run_http( handler : Handler, scope : Scope, inbound : Array[Event]) -> Array[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
fn run_http_stream( handler : StreamHandler, scope : Scope, inbound : Array[Event]) -> Array[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
fn to_asgi(handler : Handler) -> AsgiApp

Lift a synchronous Handler onto the load-bearing 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.

§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
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
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
fn LifespanHandler::new( on_startup? : (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
fn run_lifespan( handler : LifespanHandler, scope : Scope, inbound : Array[Event]) -> Array[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 [].

§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.

fn
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=..).

enum
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
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
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
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
fn WebSocketHandler::new( on_connect? : (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
fn WebSocketHandler::echo( subprotocol? : String? = None) -> 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
fn ws_run( handler : WebSocketHandler, scope : Scope, inbound : Array[Event]) -> Array[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
fn ws_run_app( app : (WebSocketScope, Array[Event]) -> Array[Event], scope : Scope, inbound : Array[Event]) -> Array[Event]

The general sans-transport WebSocket core: hand the whole inbound event stream and the 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.

§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
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
fn PushPromise::header(self : PushPromise, name : String) -> String?

First push-request header matching name.

struct
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
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
fn TestResponse::header(self : TestResponse, name : String) -> String?

First response header matching name (ASGI lowercased-name convention).

fn
fn TestResponse::trailer(self : TestResponse, name : String) -> String?

First trailing header matching name.

fn
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
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().

struct
struct TestClient

An in-process application driver — the ASGI TestClient — built on the synchronous 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 Handler / StreamHandler.

fn
fn TestClient::from_app( app : (Request) -> Array[Event], root_path? : String = "", base_headers? : Array[(String, String)] = [], client? : (String, Int)? = Some(("testclient", 50000)), server? : (String, Int?)? = Some(("testserver", Some(80))), extensions? : Extensions = Extensions::none()) -> TestClient

Build a TestClient from a raw event-emitting application (the shape 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
fn TestClient::new( handler : Handler, root_path? : String = "", base_headers? : Array[(String, String)] = [], client? : (String, Int)? = Some(("testclient", 50000)), server? : (String, Int?)? = Some(("testserver", Some(80))), extensions? : Extensions = Extensions::none()) -> TestClient

Build a TestClient from a unary Handler.

fn
fn TestClient::from_stream( handler : StreamHandler, root_path? : String = "", base_headers? : Array[(String, String)] = [], client? : (String, Int)? = Some(("testclient", 50000)), server? : (String, Int?)? = Some(("testserver", Some(80))), extensions? : Extensions = Extensions::none()) -> TestClient

Build a TestClient from a streaming StreamHandler (multi-chunk body, optional trailers).

fn
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
fn TestClient::get( self : TestClient, path : String, headers? : Array[(String, String)] = []) -> TestResponse

GET path.

fn
fn TestClient::head( self : TestClient, path : String, headers? : Array[(String, String)] = []) -> TestResponse

HEAD path.

fn
fn TestClient::delete( self : TestClient, path : String, headers? : Array[(String, String)] = []) -> TestResponse

DELETE path.

fn
fn TestClient::post( self : TestClient, path : String, body? : Bytes = b"", headers? : Array[(String, String)] = []) -> TestResponse

POST path with body.

fn
fn TestClient::put( self : TestClient, path : String, body? : Bytes = b"", headers? : Array[(String, String)] = []) -> TestResponse

PUT path with body.

fn
fn TestClient::patch( self : TestClient, path : String, body? : Bytes = b"", headers? : Array[(String, String)] = []) -> TestResponse

PATCH path with body.

struct
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
fn WsTestSession::header(self : WsTestSession, name : String) -> String?

First accept-response header matching name.

fn
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
fn TestClient::websocket( self : TestClient, path~ : String, handler~ : WebSocketHandler, send? : Array[WsMessage] = [], headers? : Array[(String, String)] = [], subprotocols? : Array[String] = [], disconnect? : Int? = Some(1000)) -> WsTestSession

Drive a 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_run core, and reassembles the server's outbound frames into a WsTestSession. No socket — testable on every backend, the WebSocket counterpart of request.

fn
fn 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? = Some(1000)) -> WsTestSession

Drive a general (WebSocketScope, Array[Event]) -> Array[Event] application through a full connection in-process. Like websocket, but for an app that consumes the whole inbound stream directly (the ws_run_app escape hatch) rather than the connect/receive/disconnect fold.

§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
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
fn ConformanceReport::ok(self : ConformanceReport) -> Bool

Whether every conformance check passed (no failures recorded).

fn
fn run_conformance() -> ConformanceReport

Run the ASGI 3.0 conformance harness: a table-driven battery that drives every Event variant and every Scope field through run_http, ws_run, and the 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().

§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
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
fn EventOrderError::describe(self : EventOrderError) -> String

A one-line human description of the violation, used in conformance failure names and server logs.

fn
fn validate_http_response(events : Array[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
fn validate_ws_response(events : Array[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
fn validate_lifespan_replies(events : Array[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
fn validate_events( scope : Scope, events : Array[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
fn validate_inbound(event : 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.

§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
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
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
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
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.

§ASGI 2.0 applications

The legacy double-callable convention a 3.0 server is encouraged to keep running. MoonBit has no signature reflection, so there is no equivalent of asgiref's guarantee_single_callable: the two conventions are distinct types and the choice is an explicit constructor.

type
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
type LegacyAsgiApp = (Scope) -> AsgiAppInstance

A legacy ASGI 2.0 double-callable application: a synchronous application(scope) that returns the AsgiAppInstance.

enum
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
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
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
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.