This version of the module is deprecated: Moved to moonbitstack/moonasgi.

    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
    Author
    Version
    0.8.1
    License
    Apache-2.0
    Last updated
    7 days ago
    Downloads
    828

    #moonasgi

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

    Check and Test License mooncakes

    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 suberror ClientDisconnected // the peer is gone

    pub type Receive = async () -> Event // pull the next inbound event
    pub type Send = async (Event) -> Unit raise ClientDisconnected // push an outbound event
    pub type AsgiApp = async (Scope, Receive, Send) -> Unit raise Error

    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. The send-side contract has its own table: a Sink that raises ClientDisconnected mid-stream, and an application that declines a lifespan scope, both driven through run_sync_app. 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)

    #Disconnection

    A connection can end under an application mid-response, and ASGI has both sides say so in different ways. Inbound it is an event — receive yields HttpDisconnect or WebSocketDisconnect — which is why Receive carries no declared error. Outbound it is an error: the www sub-spec has required since spec_version 2.4 that send() raise on a closed connection, and on this seam that error is ClientDisconnected.

    What a server owes an application. Raise ClientDisconnected from the first send after the peer goes away and from every send after that, so an application that ignores the first one cannot keep writing into nothing. The declared error is exhaustive — a server signals a dead peer this way and nothing else. And catch what comes out of the application: a ClientDisconnected means the response was already over, while any other error is the application declining or failing the scope. From a lifespan scope that is ASGI's "I do not support lifespan", and the server runs the rest of its life without lifespan; from an http or websocket scope it is a 500.

    What an application owes a server. Either let ClientDisconnected propagate — nothing it emits can arrive any more — or catch it, release what the request held, and return. It is not an application error and must not be reported as one. To decline a scope it does not implement, raise out of the callable: that is the only way ASGI gives an application to say so.

    run_sync_app is the sans-transport mirror of exactly that split, and exists for the reason run_legacy does — this package has no async runtime, so the contract is proven against a Sink (the synchronous Send) and a SyncApp (the synchronous AsgiApp), which is also how a framework on the seam can drive its own disconnect handling on every backend.

    let handled = run_sync_app(app, scope, inbound, sink) // false: the app declined the scope

    raw_path is optional the same way and for the same reason — honesty about what the server actually has. It is Bytes?, None unless the server kept the undecoded target, so an application can tell "no raw path here" from bytes that happen to equal path. A server that has them (HttpScope::from_h2_headers does) passes them in.

    #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
    optional raw_path (None when the server did not keep it)HttpScope.raw_path / WebSocketScope.raw_pathconformance scope/raw-path-*; test "raw_path is None unless the server set it"
    send() raises on a closed connection (www 2.4)ClientDisconnected on Send / Sinkconformance send/*; test "a send that raises propagates to the caller"
    Application declines a scope (ASGI's "I do not support lifespan")raising out of AsgiApp / run_sync_appconformance app/*; test "an app that raises out of a lifespan scope is distinguishable from one that returns"
    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

    One spec feature has 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.

    Both directions of disconnection are modelled. The receive side has always been — run_http_scoped_drain hands the app a Drain saying whether the client went away mid-body — and the send side is Send's declared ClientDisconnected, the www sub-spec's 2.4 requirement. The async callables cannot be driven in this package, which has no runtime by design, so their contract is type-checked here and exercised through the synchronous Sink / SyncApp mirror; driving the real async pair is mooncat's job.

    #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. It owns both halves of the disconnection contract above: raising ClientDisconnected out of its send once the peer is gone, and catching what the app raises — a ClientDisconnected it logs, anything else out of a lifespan scope it reads as "this app has no lifespan".

    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 with ClientDisconnected, 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, ws_run and run_sync_app cores, the TestClient in-process driver for both HTTP and WebSocket, the run_conformance round-trip harness (443 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 — 55 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 raise ClientDisconnected) -> 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.

    It may raise, for the two reasons the spec allows. A ClientDisconnected on its way out of send means the connection ended under the application; a server logs it and moves on. Any other error is the application declining or failing the scope — raising out of the callable is how ASGI has an application say "I do not support lifespan", so a server that catches one from a lifespan scope runs the rest of its life without lifespan, while the same error from an http or websocket scope is a 500.

    AsgiAppInstance

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

    The asynchronous instance a legacy app's first callable returns: already bound to the scope, it drives the connection with receive / send. It raises on the same terms as AsgiApp — a ClientDisconnected out of send, or its own error to decline the scope — since it is the half of the legacy convention that does the work.

    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 raise ClientDisconnected) -> 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. It carries no declared error: ASGI reports a peer that went away inbound as an event — HttpDisconnect or WebSocketDisconnect — so a server answers a receive on a dead connection with that event rather than by failing the call.

    Send

    type Send = async (Event) -> Unit raise ClientDisconnected

    Push an outbound event, raising ClientDisconnected when the peer is gone — the send-side half of the contract the www sub-spec has pinned since 2.4. The async awaitable an application calls to emit response start/body, websocket frames, or lifespan completion. The declared error is exhaustive: a server may signal a dead peer this way and nothing else.

    Sink

    type Sink = (Event) -> Unit raise ClientDisconnected

    The synchronous mirror of Send: take one outbound event, raise ClientDisconnected when the peer is gone. It exists for the reason run_legacy does — this package has no async runtime, so the send-side contract is proven through its sans-transport twin, and a framework built on the seam can drive its disconnect handling the same way on every backend.

    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.

    SyncApp

    type SyncApp = (Scope, Array[Event], (Event) -> Unit raise ClientDisconnected) -> Unit raise

    The synchronous mirror of AsgiApp: the same three arguments with an already materialised inbound stream in place of Receive and a Sink in place of Send, and the same freedom to raise — a ClientDisconnected on its way out of the sink, or the application's own error declining the scope.

    ClientDisconnected

    pub(all) suberror ClientDisconnected

    The peer is gone. A server raises this out of send when the connection the event would be written to has already closed — the www sub-spec has required send() to raise a server-specific error since spec_version 2.4, and on this seam that error is this one.

    What a server owes an application: raise it from the first send after the client disconnects, and from every send after that, so an application that ignores it cannot keep writing into nothing. What an application owes a server: either let it propagate — nothing it emits can arrive any more — or catch it, release what the request held, and return. It is not an application error and a server does not answer 500 to it; the response is already over.

    AsgiApplication

    pub(all) enum AsgiApplication {
    Single(async (Scope, async () -> Event, async (Event) -> Unit raise ClientDisconnected) -> Unit)
    Legacy((Scope) -> (async (async () -> Event, async (Event) -> Unit raise ClientDisconnected) -> 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 (ClientDisconnected), 2.5 a websocket disconnect reason.

    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. raw_path is optional in the spec and so here: Some when the server kept the undecoded target bytes, None when it did not, which is not the same claim as "they happen to equal path". A router that cares where %2F sat reads it and has to handle its absence. 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 stays None unless the caller passes it: the spec defaults it to None, and synthesising it from path would tell an application the server kept bytes it never saw. 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. raw_path is optional for the same reason as on an http scope — None says the server did not keep the undecoded target. 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 stays None unless the caller passes it.

    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 raise ClientDisconnected) -> Unit)) -> (async (Scope, async () -> Event, async (Event) -> Unit raise ClientDisconnected) -> 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 raise ClientDisconnected) -> 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 [].

    run_sync_app

    fn run_sync_app(app : (Scope, Array[Event], (Event) -> Unit raise ClientDisconnected) -> Unit raise, scope : Scope, inbound : Array[Event], sink : (Event) -> Unit raise ClientDisconnected) -> Bool raise ClientDisconnected

    Drive a synchronous application and report whether it handled the scope. false means it raised out of the callable: from a lifespan scope that is ASGI's "I do not support lifespan" and a server carries on without lifespan; from an http or websocket scope it is an application error and a server answers 500. A ClientDisconnected is not the application failing but the peer leaving, so it propagates to the caller instead of being reported as one — the same split a server makes when it drives the async AsgiApp.

    to_asgi

    fn to_asgi(handler : (Request) -> Response) -> (async (Scope, async () -> Event, async (Event) -> Unit raise ClientDisconnected) -> 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. A ClientDisconnected out of send propagates: the handler has already run and there is nothing left to write, so there is nothing this wrapper could usefully do with it.

    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.