moonasgi

    MoonBit-dialect ASGI 3.0 — the load-bearing server↔app SEAM (Scope / Receive / Send) that the moon* full-stack web suite (mooncat, moonapi, moonrpc, moongql, moonzero) is built around.

    asgi
    moonbit
    web
    server-interface
    async
    http
    Download zip
    Version
    0.8.2
    License
    Apache-2.0
    Last updated
    3 hours ago
    Downloads
    27

    #moonasgi

    MoonBit-dialect ASGI 3.0 — the load-bearing server↔app SEAM.

    Check and Test License mooncakes

    Moved on mooncakes from Lfan-ke/moonasgi to moonbitstack/moonasgi.

    moonasgi is the backend-agnostic protocol seam every server and framework in the moon* full-stack suite is built around. One typed contract — Scope / Receive / Send — isolates async-transport churn in a single server adapter, so the framework repos (moonapi, moonorm, moonrpc, moongql, moonzero) never depend on moonbitlang/async directly.

    flowchart LR net["native async<br/>(sockets, TLS, HTTP/1.1, HTTP/2)"] --> cat["mooncat<br/>(ASGI server)"] cat -->|"Scope / Receive / Send"| asgi(["**moonasgi**<br/>SEAM"]) asgi --> api["moonapi"] asgi --> rpc["moonrpc"] asgi --> gql["moongql"] asgi --> zero["moonzero"] api --> orm["moonorm"]

    #The contract

    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

    Applications are usually written against the ergonomic sugar, which the server lifts onto AsgiApp:

    pub type Handler = (Request) -> Response
    pub type Middleware = (Handler) -> Handler

    Event is a typed sum over the full http / websocket / lifespan message sets in both directions — including the standard extension messages — replacing ASGI's stringly-typed message dicts.

    #TestClient

    Drive any application in-process, with no socket, on every backend. The TestClient is built on the synchronous run_http_app core: it fabricates a scope, feeds the request body in, runs the app, and reassembles the outbound event stream into a captured response.

    let client = TestClient::new(handler) // or ::from_stream / ::from_app
    let r = client.post("/echo", body=b"payload")
    assert_eq(r.status, 200)
    assert_eq(r.text(), "payload")

    It reassembles a streamed multi-chunk body, captures response trailers, server-push promises, early hints, and the path-send target, and can stream a chunked request body in via request(..., chunks=[...]).

    #WebSocket

    WebSocket app logic is testable in-process too, through the synchronous core ws_run — the WebSocket analog of run_http. A WebSocketHandler is written in the connect / receive / disconnect shape; the TestClient drives a whole connection and captures the result as a WsTestSession.

    let client = TestClient::new(handler)
    let session = client.websocket(
    path="/chat",
    handler=WebSocketHandler::echo(subprotocol=Some("chat")),
    send=[Text("hello"), Binary(b"\x01\x02")],
    )
    assert_eq(session.accepted, true)
    assert_eq(session.subprotocol, Some("chat"))
    assert_eq(session.texts(), ["hello"])

    A handler can Accept (negotiating a subprotocol and response headers), Reject with a bare close code, or DenyHttp with a full HTTP response (the websocket.http.response extension). ws_run_app is the escape hatch for apps whose control flow is not the connect/receive/disconnect fold — it hands the whole inbound stream and full scope to the app.

    #Lifespan

    The lifespan protocol has the same shape as the http and WebSocket cores. A LifespanHandler is written as a startup and a shutdown hook; run_lifespan drives it over the inbound LifespanStartup / LifespanShutdown events and returns the replies a server sends. Startup seeds scope.state in place — the map a server then copies onto every request scope, which is how a DB pool or loaded config reaches each request. A failed startup short-circuits: the server never proceeds to shutdown, exactly as ASGI pins.

    let app = LifespanHandler::new(
    on_startup=fn(scope) {
    scope.state["db_pool"] = Json::string("pool://greet") // seeded in place
    Complete
    },
    )
    let out = run_lifespan(app, Lifespan(scope), [LifespanStartup])
    assert_eq(out == [LifespanStartupComplete], true)

    #Scope-aware core

    The ergonomic Handler sees a Request — method, path, query, headers, body. A framework needs more: root_path for mounted sub-apps, extensions to gate a feature on what the server advertises, client for security, and state seeded by lifespan. run_http_scoped hands the app the full HttpScope alongside the drained body, and run_http_app is the Request-level wrapper over it.

    run_http_scoped(fn(scope, body) {
    let prefix = scope.state.get("greeting_prefix") // lifespan-seeded
    [HttpResponseStart(status=200, headers=[], trailers=false),
    HttpResponseBody(body, more_body=false)]
    }, Http(scope), inbound)

    #HTTP/2 and HTTP/3

    http_version on the scope is "1.0", "1.1", "2", or "3" — the SEAM is transport-agnostic, so an app runs unchanged whichever version served the request. The one place the version leaks into wiring is the request line: an HTTP/2 (RFC 7540 §8.1.2.3) or HTTP/3 request carries it as pseudo-headers (:method, :scheme, :authority, :path) interleaved with the ordinary fields, and ASGI has no slot for them in scope["headers"]. HttpScope::from_h2_headers owns that lowering so every h2/h2c/h3 transport in the suite (mooncat) produces the same scope from the same HEADERS block — pseudo-headers consumed into the typed fields, host synthesised from :authority, and no :-prefixed header ever reaching the app.

    let scope = HttpScope::from_h2_headers([
    (":method", "POST"), (":scheme", "https"),
    (":authority", "example.test"), (":path", "/items?page=2"),
    ("content-type", "application/json"),
    ])
    // -> Ok: http_method="POST", scheme="https", path="/items",
    // query_string=b"page=2", headers=[("host","example.test"), ...]

    A malformed pseudo-header set — a missing required one, a duplicate, an unknown :foo, or a pseudo-header after an ordinary field — returns Err(Http2HeaderError) naming the first violation, so the transport can answer RST_STREAM(PROTOCOL_ERROR) instead of forwarding a bad scope.

    #Conformance

    run_conformance() is a table-driven harness. Every Event variant is checked for round-trip fidelity and for being distinct from every other one; the http, websocket and lifespan scopes and their ordering rules are driven through run_http / ws_run / TestClient. The out-of-band extension messages — push, pathsend, zero-copy send, debug, disconnect — are covered by the ordering tables rather than by a driver, since no driver emits them. It returns a ConformanceReport naming any failing check, so a downstream server can self-verify the seam wiring:

    let report = run_conformance()
    assert_eq(report.ok(), true)

    The harness also runs a negative table: ASGI pins the order an app may emit messages in, and validate_events(scope, events) walks an outbound stream and names the first violation — a body before the response starts, a second http.response.start, a websocket frame before the handshake is accepted, a lifespan shutdown reply before startup, and the rest. A server can run it over its own emissions to reject a buggy app early.

    let bad = [HttpResponseBody(body=b"x", more_body=false)] // no start
    assert_eq(validate_events(scope, bad) == Some(BodyBeforeStart), true)

    #Extensions & streaming

    The standard ASGI extensions are modelled as typed events and scope fields, not stringly-typed dicts:

    • http.response.trailersStreamingResponse.trailers; lowered to HttpResponseStart{trailers: true} + a terminating HttpResponseTrailers.
    • http.response.pushHttpResponsePush{path, headers}.
    • http.response.pathsendHttpResponsePathSend{path}.
    • http.response.zerocopysendHttpResponseZeroCopySend{fd, offset, count, more_body}; captured by the TestClient as TestResponse.zerocopysend.
    • http.response.early_hintHttpResponseEarlyHint{links}; StreamingResponse.early_hints, lowered to 103 Early Hints messages ahead of the response.
    • http.response.debugHttpResponseDebug{info}; an out-of-band info payload with no protocol meaning, valid anywhere in the response.
    • websocket.http.responseWebSocketHttpResponseStart / WebSocketHttpResponseBody, to deny a handshake with a full HTTP response.
    • tlsTlsExtension carried on the scope's extensions.

    A server advertises what it honours via the typed Extensions capability flags; response streaming (multiple HttpResponseBody with more_body: true) is modelled by StreamingResponse and driven by run_http_stream. spec_version is negotiated numerically with AsgiVersion::at_least, and each sub-protocol carries its own default — AsgiVersion::http() / websocket() (2.5, the 2024-06-05 revision) and lifespan() (2.0). The 2.5 websocket revision adds reason to WebSocketDisconnect, threaded to a handler's on_disconnect(code, reason).

    #Compliance matrix

    Every ASGI 3.0 spec feature, and the test that exercises it. conformance is run_conformance()'s in-suite battery; test names are the whitebox tests.

    Spec featureModelled asCovered by
    http scope (all fields incl. state, raw_path, root_path)HttpScopeconformance scope/http-fields
    websocket scope (incl. subprotocols, state)WebSocketScopeconformance scope/ws-fields
    lifespan scope (incl. state)LifespanScopeconformance scope/lifespan-fields
    asgi version / spec_version (2.5 http+ws, 2.0 lifespan)AsgiVersionconformance spec_version/*; test "per-subprotocol asgi spec_version defaults"
    spec_version numeric negotiationAsgiVersion::at_leasttest "AsgiVersion negotiates spec_version numerically"
    Every http/ws/lifespan message, both directionsEvent (25 variants)conformance event/*; test "SEAM scope and event variants construct"
    Full scope reaches a framework (state, root_path, extensions)run_http_scopedconformance scoped/*; test "greet: moonapi http routing round-trips…"
    lifespan.startup / .shutdown (complete / failed, in-place state)LifespanHandler / run_lifespanconformance lifespan/*; test "greet: lifespan startup seeds state…"
    http.request drain (streamed more_body)run_http / TestClienttest "TestClient streams a chunked request body into the handler"
    http.disconnectHttpDisconnectconformance event/*
    http.response.start + .body round-tripResponse / run_httpconformance http/roundtrip; test "run_http drains a chunked body…"
    Response streaming (more_body: true chunks)StreamingResponse / run_http_streamtest "TestClient reassembles a streaming multi-chunk response body"
    websocket.connect / .accept / .receive / .send / .closeWebSocketHandler / ws_runtest "ws_run echoes text and binary frames…"
    websocket.disconnect (2.5 reason)WebSocketDisconnecttest "on_disconnect receives the 2.5 close code and reason"
    Handshake reject (bare close)WsAccept::Rejecttest "ws handler can reject the handshake with a close code"
    ext http.response.trailersStreamingResponse.trailerstest "TestClient captures response trailers"
    ext http.response.pushHttpResponsePushtest "TestClient captures server push and pathsend"
    ext http.response.pathsendHttpResponsePathSendtest "TestClient captures server push and pathsend"
    ext http.response.zerocopysendHttpResponseZeroCopySendtest "zerocopysend and debug extensions round-trip…"
    ext http.response.early_hintHttpResponseEarlyHinttest "early-hint extension round-trips through the TestClient"
    ext http.response.debugHttpResponseDebugtest "zerocopysend and debug extensions round-trip…"
    ext websocket.http.response (denial)WsAccept::DenyHttptest "ws handler can deny the handshake with a full HTTP response"
    ext tlsTlsExtensiontest "Extensions builder advertises capabilities and TLS data"
    Message-ordering rules (all message sets)validate_eventsconformance order/*; test "validator names the violation…"
    HTTP/2·3 pseudo-header → scope lowering (:method/:scheme/:authority/:path, host synthesis, malformed rejection)HttpScope::from_h2_headers / Http2HeaderErrorconformance h2/*
    http_version transport-agnostic seam ("1.1" / "2" / "3")HttpScope.http_versionconformance h2/seam-agnostic

    Two spec features have no direct model here. The C-level file object in http.response.zerocopysend is carried as an Int fd, because this seam is socket-free by design and the actual sendfile is the server adapter's (mooncat) job. And send() cannot raise on a closed connection, which the www sub-spec has required since version 2.4: Send is declared async (Event) -> Unit, so a server has no in-band way to tell an application the client went away mid-response. A body drain reports it — run_http_scoped_drain hands the app a Drain saying whether the client disconnected — but the send path does not.

    #Consumed by

    The seam is the only thing the server and the frameworks share. Each binds to a specific slice of it.

    mooncat — the ASGI server. Owns the async Receive / Send and the native transport, binds AsgiApp, and drives every core: it drains the http request body, runs run_lifespan at boot and teardown, and drives WebSocket connections. It copies LifespanScope.state onto each HttpScope.state, serialises the outbound Event stream (HttpResponseStart / HttpResponseBody / HttpResponseTrailers / the extension messages) to the socket, and can run validate_events over an app's emissions to reject a buggy app before writing.

    moonapi — the web framework. Binds at the scope level through run_http_scoped, because it reads what the ergonomic Request drops: HttpScope.root_path (mounted sub-apps), extensions (gate a response push or path-send on what the server advertises), client (security), and state (the lifespan-seeded pool / config). It returns Response and StreamingResponse, serves its OpenAPI document as an ordinary response, and registers LifespanHandler startup / shutdown hooks that mooncat runs.

    moonzero — the service assembler. Stacks its middleware (request-id, CORS, logging, recovery) as Middleware values and folds them over a Handler with compose, outermost-first.

    The greet: * whitebox tests assemble a representative app across all three shapes and drive it through these exact seam points, so the seam is proven greet-ready before any consuming repo is wired up.

    #Design

    • Zero dependencies. The seam is pure types plus a small sugar layer; the concrete moonbitlang/async transport types live only in the mooncat adapter. Async or backend churn stops at the seam (see the isolation contract).
    • Faithful, not approximate. This is a complete transliteration of ASGI 3.0 into MoonBit idiom — the typed Scope/Event shape is the dialect, not a subset.

    #Status

    v0 — the SEAM (Scope, Event, Receive/Send/AsgiApp, Request/Response, Handler/Middleware) plus the full ASGI-extension event set (push, pathsend, zero-copy send, trailers, early hints, debug, WebSocket denial, TLS), StreamingResponse response streaming, the synchronous run_http, run_http_scoped, run_lifespan, and ws_run cores, the TestClient in-process driver for both HTTP and WebSocket, the run_conformance round-trip harness (436 checks) with its validate_events ordering table, HttpScope::from_h2_headers HTTP/2·3 pseudo-header lowering, and typed Extensions/tls/spec_version (2.5) scope metadata — 51 tests, warning-clean across every backend. The async Handler → AsgiApp runtime adapter lands with mooncat, which owns the native async transport.

    #License

    Apache-2.0.

    AsgiApp

    type AsgiApp = async (Scope, async () -> Event, async (Event) -> Unit) -> 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.

    AsgiAppInstance

    type AsgiAppInstance = async (async () -> Event, async (Event) -> Unit) -> Unit

    The asynchronous instance a legacy app's first callable returns: already bound to the scope, it drives the connection with receive / send.

    Handler

    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.

    LegacyAsgiApp

    type LegacyAsgiApp = (Scope) -> (async (async () -> Event, async (Event) -> Unit) -> Unit)

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

    Middleware

    type Middleware = ((Request) -> Response) -> ((Request) -> Response)

    A handler transformer that wraps a downstream Handler to add cross-cutting behaviour. Composed as an onion where the first registered is outermost.

    Receive

    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.

    Send

    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.

    StreamHandler

    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.

    AsgiApplication

    pub(all) enum AsgiApplication {
    Single(async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)
    Legacy((Scope) -> (async (async () -> Event, async (Event) -> Unit) -> Unit))
    }

    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.

    AsgiVersion

    pub(all) struct AsgiVersion {
    version : String
    spec_version : String
    } derive(Eq)

    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.

    AsgiVersion::at_least

    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.

    AsgiVersion::default

    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.

    AsgiVersion::http

    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.

    AsgiVersion::lifespan

    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.

    AsgiVersion::websocket

    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.

    ConformanceReport

    pub(all) struct ConformanceReport {
    total : Int
    passed : Int
    failures : Array[String]
    } derive(Eq)

    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.

    ConformanceReport::ok

    fn ConformanceReport::ok(self : ConformanceReport) -> Bool

    Whether every conformance check passed (no failures recorded).

    Drain

    pub(all) enum Drain {
    Complete
    Disconnected
    Truncated
    } derive(Eq,
    Debug
    )

    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.

    Event

    pub(all) enum Event {
    HttpRequest(body~ : Bytes, more_body~ : Bool)
    HttpDisconnect
    HttpResponseStart(status~ : Int, headers~ : Array[(String, String)], trailers~ : Bool)
    HttpResponseBody(body~ : Bytes, more_body~ : Bool)
    HttpResponseTrailers(headers~ : Array[(String, String)], more_trailers~ : Bool)
    HttpResponsePush(path~ : String, headers~ : Array[(String, String)])
    HttpResponsePathSend(path~ : String)
    HttpResponseZeroCopySend(fd~ : Int, offset~ : Int?, count~ : Int?, more_body~ : Bool)
    HttpResponseDebug(info~ : Json)
    HttpResponseEarlyHint(links~ : Array[String])
    WebSocketConnect
    WebSocketReceive(text~ : String?, bytes~ : Bytes?)
    WebSocketDisconnect(code~ : Int, reason~ : String?)
    WebSocketAccept(subprotocol~ : String?, headers~ : Array[(String, String)])
    WebSocketSendText(String)
    WebSocketSendBytes(Bytes)
    WebSocketClose(code~ : Int, reason~ : String)
    WebSocketHttpResponseStart(status~ : Int, headers~ : Array[(String, String)], trailers~ : Bool)
    WebSocketHttpResponseBody(body~ : Bytes, more_body~ : Bool)
    LifespanStartup
    LifespanShutdown
    LifespanStartupComplete
    LifespanStartupFailed(message~ : String)
    LifespanShutdownComplete
    LifespanShutdownFailed(message~ : String)
    Other(type_~ : String, payload~ : Json)
    } derive(Eq)

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

    EventOrderError

    pub(all) enum EventOrderError {
    BodyBeforeStart
    DuplicateResponseStart
    EarlyHintAfterStart
    BodyAfterComplete
    UnexpectedTrailers
    TrailersBeforeBody
    MissingResponseStart
    IncompleteBody
    MissingTrailers
    EventAfterComplete
    DebugAfterStart
    DuplicateDebug
    PushBeforeStart
    PathSendMixedWithBody
    IllegalResponseHeader
    FrameBeforeAccept
    DuplicateAccept
    EventAfterClose
    DenialBodyBeforeStart
    DenialAfterAccept
    AcceptAfterDenial
    IncompleteDenial
    MissingHandshakeReply
    DuplicateLifespanReply
    ShutdownBeforeStartup
    NonResponseEvent
    } derive(Eq)

    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.

    EventOrderError::describe

    fn EventOrderError::describe(self : EventOrderError) -> String

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

    Extensions

    pub(all) struct Extensions {
    tls : TlsExtension?
    http_response_push : Bool
    http_response_trailers : Bool
    http_response_pathsend : Bool
    http_response_zerocopysend : Bool
    http_response_early_hint : Bool
    http_response_debug : Bool
    websocket_http_response : Bool
    custom : Map[String, Json]
    } derive(Eq)

    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"].

    Extensions::enable

    fn Extensions::enable(self : Extensions, name : String, value? : Json) -> 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).

    Extensions::enable_debug

    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.

    Extensions::enable_early_hint

    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.

    Extensions::enable_pathsend

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

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

    Extensions::enable_push

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

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

    Extensions::enable_trailers

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

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

    Extensions::enable_websocket_http_response

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

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

    Extensions::enable_zerocopysend

    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.

    Extensions::get

    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.

    Extensions::none

    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.

    Extensions::with_tls

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

    Attach tls extension data to a connection scope.

    Http2HeaderError

    pub(all) enum Http2HeaderError {
    MissingMethod
    MissingScheme
    MissingPath
    EmptyPath
    DuplicatePseudoHeader(String)
    UnknownPseudoHeader(String)
    PseudoHeaderAfterRegular(String)
    } derive(Eq)

    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.

    HttpScope

    pub(all) struct HttpScope {
    http_version : String
    http_method : String
    scheme : String
    path : String
    raw_path : Bytes
    query_string : Bytes
    root_path : String
    headers : Array[(String, String)]
    client : (String, Int)?
    server : (String, Int?)?
    asgi : AsgiVersion
    extensions : Extensions
    state : Map[String, Json]
    }

    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.

    HttpScope::from_h2_headers

    fn HttpScope::from_h2_headers(headers : Array[(String, String)], http_version? : String, root_path? : String, client? : (String, Int)?, server? : (String, Int?)?, extensions? : Extensions, asgi? : AsgiVersion, state? : Map[String, Json]) -> Result[HttpScope, Http2HeaderError]

    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.

    HttpScope::new

    fn HttpScope::new(http_method~ : String, path~ : String, http_version? : String, scheme? : String, raw_path? : Bytes, query_string? : Bytes, root_path? : String, headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, asgi? : AsgiVersion, extensions? : Extensions, state? : Map[String, Json]) -> 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.

    LifespanHandler

    pub(all) struct LifespanHandler {
    on_startup : (LifespanScope) -> LifespanReply
    on_shutdown : (LifespanScope) -> LifespanReply
    }

    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.

    LifespanHandler::new

    fn LifespanHandler::new(on_startup? : (LifespanScope) -> LifespanReply, on_shutdown? : (LifespanScope) -> LifespanReply) -> LifespanHandler

    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.

    LifespanReply

    pub(all) enum LifespanReply {
    Complete
    Failed(message~ : String)
    } derive(Eq)

    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.

    LifespanScope

    pub(all) struct LifespanScope {
    asgi : AsgiVersion
    state : Map[String, Json]
    }

    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.

    LifespanScope::new

    fn LifespanScope::new(asgi? : AsgiVersion, state? : Map[String, Json]) -> LifespanScope

    Build a LifespanScope with the lifespan asgi handshake (sub-spec 2.0) and an empty state by default.

    PushPromise

    pub(all) struct PushPromise {
    path : String
    headers : Array[(String, String)]
    } derive(Eq)

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

    PushPromise::header

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

    First push-request header matching name.

    Request

    pub(all) struct Request {
    http_method : String
    path : String
    query_string : Bytes
    headers : Array[(String, String)]
    body : Bytes
    } derive(Eq)

    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.

    Request::header

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

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

    Response

    pub(all) struct Response {
    status : Int
    headers : Array[(String, String)]
    body : Bytes
    } derive(Eq)

    An outbound HTTP response: status, headers, and the full body. Mutable so middleware can decorate it before the server serialises it into HttpResponseStart + HttpResponseBody.

    Response::events

    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.

    Response::header

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

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

    Response::json

    fn Response::json(status? : Int, 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.

    Response::new

    fn Response::new(status : Int, headers : Array[(String, String)], body : Bytes) -> Response

    Build a response from raw bytes.

    Response::text

    fn Response::text(status? : Int, 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.

    Scope

    pub(all) enum Scope {
    Http(HttpScope)
    WebSocket(WebSocketScope)
    Lifespan(LifespanScope)
    }

    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.

    StreamingResponse

    pub(all) struct StreamingResponse {
    status : Int
    headers : Array[(String, String)]
    chunks : Array[Bytes]
    trailers : Array[(String, String)]
    early_hints : Array[Array[String]]
    } derive(Eq)

    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.

    StreamingResponse::events

    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.

    StreamingResponse::new

    fn StreamingResponse::new(chunks~ : Array[Bytes], status? : Int, 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.

    TestClient

    pub(all) struct TestClient {
    app : (Request) -> Array[Event]
    root_path : String
    base_headers : Array[(String, String)]
    client : (String, Int)?
    server : (String, Int?)?
    extensions : Extensions
    }

    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.

    TestClient::delete

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

    DELETE path.

    TestClient::from_app

    fn TestClient::from_app(app : (Request) -> Array[Event], root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, extensions? : Extensions) -> 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.

    TestClient::from_stream

    fn TestClient::from_stream(handler : (Request) -> StreamingResponse, root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, extensions? : Extensions) -> TestClient

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

    TestClient::get

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

    GET path.

    TestClient::head

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

    HEAD path.

    TestClient::new

    fn TestClient::new(handler : (Request) -> Response, root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, extensions? : Extensions) -> TestClient

    Build a TestClient from a unary Handler.

    TestClient::patch

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

    PATCH path with body.

    TestClient::post

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

    POST path with body.

    TestClient::put

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

    PUT path with body.

    TestClient::request

    fn TestClient::request(self : TestClient, http_method~ : String, path~ : String, headers? : Array[(String, String)], body? : Bytes, http_version? : String, 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.

    TestClient::websocket

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

    TestClient::websocket_app

    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?) -> 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.

    TestResponse

    pub(all) struct TestResponse {
    status : Int
    headers : Array[(String, String)]
    body : Bytes
    trailers : Array[(String, String)]
    pushes : Array[PushPromise]
    pathsend : String?
    zerocopysend : ZeroCopySend?
    debug : Array[Json]
    early_hints : Array[Array[String]]
    } derive(Eq)

    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.

    TestResponse::header

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

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

    TestResponse::json

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

    TestResponse::text

    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.

    TestResponse::trailer

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

    First trailing header matching name.

    TlsExtension

    pub(all) struct TlsExtension {
    server_cert : String?
    client_cert_chain : Array[String]
    client_cert_name : String?
    client_cert_error : String?
    tls_version : Int?
    cipher_suite : Int?
    } derive(Eq)

    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.

    WebSocketHandler

    pub(all) struct WebSocketHandler {
    on_connect : (WebSocketScope) -> WsAccept
    on_receive : (WsMessage) -> Array[WsSend]
    on_disconnect : (Int, String?) -> Array[WsSend]
    }

    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.

    WebSocketHandler::echo

    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.

    WebSocketHandler::new

    fn WebSocketHandler::new(on_connect? : (WebSocketScope) -> WsAccept, on_receive? : (WsMessage) -> Array[WsSend], on_disconnect? : (Int, String?) -> Array[WsSend]) -> WebSocketHandler

    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.

    WebSocketScope

    pub(all) struct WebSocketScope {
    http_version : String
    scheme : String
    path : String
    raw_path : Bytes
    query_string : Bytes
    root_path : String
    headers : Array[(String, String)]
    client : (String, Int)?
    server : (String, Int?)?
    subprotocols : Array[String]
    asgi : AsgiVersion
    extensions : Extensions
    state : Map[String, Json]
    }

    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.

    WebSocketScope::new

    fn WebSocketScope::new(path~ : String, http_version? : String, scheme? : String, raw_path? : Bytes, query_string? : Bytes, root_path? : String, headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, subprotocols? : Array[String], asgi? : AsgiVersion, extensions? : Extensions, state? : Map[String, Json]) -> 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.

    WebSocketScope::select_subprotocol

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

    WsAccept

    pub(all) enum WsAccept {
    Accept(subprotocol~ : String?, headers~ : Array[(String, String)])
    Reject(code~ : Int, reason~ : String)
    DenyHttp(status~ : Int, headers~ : Array[(String, String)], body~ : Bytes)
    } derive(Eq)

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

    WsMessage

    pub(all) enum WsMessage {
    Text(String)
    Binary(Bytes)
    } derive(Eq)

    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.

    WsSend

    pub(all) enum WsSend {
    SendText(String)
    SendBinary(Bytes)
    Close(code~ : Int, reason~ : String)
    } derive(Eq)

    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.

    WsTestSession

    pub(all) struct WsTestSession {
    accepted : Bool
    subprotocol : String?
    accept_headers : Array[(String, String)]
    messages : Array[WsMessage]
    closed : Bool
    close_code : Int?
    close_reason : String?
    denial : TestResponse?
    } derive(Eq)

    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.

    WsTestSession::header

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

    First accept-response header matching name.

    WsTestSession::texts

    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.

    ZeroCopySend

    pub(all) struct ZeroCopySend {
    fd : Int
    offset : Int?
    count : Int?
    } derive(Eq)

    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.

    compose

    fn compose(middlewares : Array[((Request) -> Response) -> ((Request) -> Response)], base : (Request) -> Response) -> ((Request) -> Response)

    Compose middlewares over a base handler. The first element is the outermost wrapper, matching registration order.

    double_to_single_callable

    fn double_to_single_callable(app : (Scope) -> (async (async () -> Event, async (Event) -> Unit) -> Unit)) -> (async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)

    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.

    guarantee_single_callable

    fn guarantee_single_callable(app : AsgiApplication) -> (async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)

    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.

    headers_from_wire

    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.

    headers_to_wire

    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.

    latin1_decode

    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.

    latin1_encode

    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.

    percent_decode

    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.

    run_conformance

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

    run_http

    fn run_http(handler : (Request) -> Response, 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.

    run_http_app

    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 [].

    run_http_scoped

    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 [].

    run_http_scoped_drain

    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 [].

    run_http_stream

    fn run_http_stream(handler : (Request) -> StreamingResponse, 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.

    run_legacy

    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.

    run_lifespan

    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 [].

    to_asgi

    fn to_asgi(handler : (Request) -> Response) -> (async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)

    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.

    validate_events

    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.

    validate_http_response

    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.

    validate_inbound

    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.

    validate_lifespan_replies

    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.

    validate_ws_response

    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.

    ws_run

    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.

    ws_run_app

    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.