Sign in

    moonasgi

    The ASGI interface for MoonBit: servers, applications and the middleware between them.

    asgi
    web
    server
    Download zip
    Version
    0.10.3
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    659

    #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 frameworks that sit on it (moonapi, moongql, moonzero, moonmcp) never touch the transport.

    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 --> gql["moongql"] asgi --> zero["moonzero"] asgi --> mcp["moonmcp"]

    #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 a handler 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=[...]).

    #Testing an async application

    moonasgi/testing runs a real AsgiApp in-process on moonbitlang/async, as asgiref's testing and Starlette's TestClient do, on the native, wasm and js backends. An error the application raises reaches the test, whether it comes before the answer or after it.

    let client = @testing.Client::new(app)
    client.lifespan(() => { // startup, body, shutdown
    let r = client.post("/items", body=b"{}")
    assert_eq(r.status, 201)
    client.websocket(path="/chat", subprotocols=["v1"], ws => {
    ws.send_text("hi")
    assert_eq(ws.receive_text(), "hi")
    }) // closes with 1000, waits for the app
    })

    communicate is the event-level form, asgiref's ApplicationCommunicator:

    @testing.communicate(app, scope, c => {
    c.send(HttpRequest(body=b"", more_body=false))
    assert_true(c.receive() is HttpResponseStart(status=200, ..))
    assert_true(c.receive_nothing())
    c.wait()
    })

    asgiref / StarletteHereDefault wait
    ApplicationCommunicator(app, scope)communicate(app, scope, body); the application is cancelled when body returns—
    send_input, receive_output, receive_nothing, waitsend, receive, receive_nothing, wait1 s; 100 ms for receive_nothing
    TestClient(app), with client:Client::new(app), client.lifespan(body)1 s per wait
    client.websocket_connect(path)client.websocket(path~, body) with a Session—

    Two things asgiref does differently: it raises an application's error as soon as the application has failed, dropping what it sent before; here those events come out first. And once the application has returned, receive raises Finished at once instead of waiting out the timeout. Every way a wait can end has a name in Refused: Timeout, Finished, Closed, Denied, Startup, Shutdown and Unexpected.

    #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="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.trailers — StreamingResponse.trailers; lowered to HttpResponseStart{trailers: true} + a terminating HttpResponseTrailers.
    • http.response.push — HttpResponsePush{path, headers}.
    • http.response.pathsend — HttpResponsePathSend{path}.
    • http.response.zerocopysend — HttpResponseZeroCopySend{fd, offset, count, more_body}; captured by the TestClient as TestResponse.zerocopysend.
    • http.response.early_hint — HttpResponseEarlyHint{links}; StreamingResponse.early_hints, lowered to 103 Early Hints messages ahead of the response.
    • http.response.debug — HttpResponseDebug{info}; an out-of-band info payload with no protocol meaning, valid anywhere in the response.
    • websocket.http.response — WebSocketHttpResponseStart / WebSocketHttpResponseBody, to deny a handshake with a full HTTP response.
    • tls — TlsExtension 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.

    #Middleware

    moonasgi/mid wraps any ASGI application, whatever framework made it, the way Starlette's middleware does. Each middleware is a record implementing Layer; a middleware of your own implements it too, or is a plain function wrapped in Wrap, and stacks with them on equal terms.

    let app = @mid.stack(
    [
    @mid.ServerErrors::new(), // outermost
    @mid.TrustedHosts::new(hosts=["example.com", "*.example.com"]),
    @mid.Cors::new(origins=["https://app.example.com"], methods=["GET", "POST"]),
    @mid.gzip,
    @mid.Wrap(inner => my_timing(inner)),
    ],
    framework_app,
    )

    MiddlewareWhat it doesDefaults
    CorsPreflights answered here (200, or 400 naming the refusal), the origin echoed with credentials or a cookieno origin, GET, the safelisted headers, 600 s
    GzipCompresses a single-message body of minimum octets or more, when gzip is accepted500 octets, level 9
    TrustedHostsOther hosts get 400; a bare domain goes to its listed www.any host, redirect on
    HttpsRedirecthttp to https, ws to wss, by 307—
    ServerErrorsA 500 if the answer has not started, then the error raised onno detail; debug or a handler to say more
    ProxyHeadersFrom a trusted proxy, X-Forwarded-For and X-Forwarded-Proto (or RFC 7239 Forwarded) become the scope's client and schemetrusts 127.0.0.1
    BodyLimitA request body over the limit is answered 413, by its Content-Length or as it streams in1 MiB

    Two differences, both deliberate: a body sent in several messages is not compressed, so a stream stays a stream; and Cors has no origin regex yet. Each record has a preset (@mid.cors, @mid.gzip, ...) and a ::new, and { ..@mid.gzip, level: 1 } says the same as Gzip::new(level=1).

    #Several applications behind one

    moonasgi/dispatch puts several ASGI applications behind one, so one server carries an API, an MCP endpoint and a site:

    let app = @dispatch.mount(
    [("/api", api_app), ("/mcp", mcp_app)],
    fallback=site_app,
    )
    let by_host = @dispatch.hosts([("docs.example.com", docs_app)], fallback=app)

    mount matches whole path segments and prefers the longest prefix; the application chosen sees the path below its prefix and the prefix added to root_path. hosts matches the Host header, *.example.com for every name below a domain. A path or host nothing takes gets 404, or a declined handshake for a WebSocket. The lifespan reaches every application: startup in order, shutdown in reverse, one failed startup failing them all; an application that does not take part in the lifespan is left out of it.

    #Synchronous applications

    moonasgi/wsgi runs a synchronous application, the WSGI shape, on an ASGI server:

    let legacy : @wsgi.WsgiApp = (environ, start) => {
    ignore(start("200 OK", [("Content-Type", "text/plain")]))
    ["hello from ", environ.path_info].iter().map(s => @utf8.encode(s))
    }
    let app = @wsgi.to_asgi(legacy)

    Environ carries the CGI variables as fields (script_name is the mount point, path_info the rest), the request headers and the body; to_map gives them under their CGI names. The request body is read before the application runs; each chunk it yields, or writes through what start_response returns, is sent as it comes. A WebSocket is declined and the lifespan answered.

    #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. Wraps its services in middleware (request-id, CORS, logging, recovery) folded over the application, 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

    • The seam has no dependencies. spec and the root are pure types plus a small sugar layer; the transport lives in the mooncat adapter. Three packages bring one in, which is why each is a package of its own: mid uses moonzip for gzip, and testing and dispatch use moonbitlang/async for its event loop.
    • A closed connection says so. A server raises spec.Closed from send once the connection has closed, so an application that keeps sending learns it and stops.
    • 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

    The seam (Scope, Event, Receive, Send, AsgiApp, Request, Response), the full extension set, the synchronous cores (run_http, run_http_scoped, run_lifespan, ws_run), TestClient for HTTP and WebSocket, the conformance harness and its ordering table, HTTP/2 and HTTP/3 pseudo-header lowering, the middleware in mid, the async test driver in testing, several applications behind one in dispatch, and synchronous applications in wsgi. Warning-clean on every backend; the tests that drive async applications run where moonbitlang/async has an event loop: native, wasm and js.

    #License

    Apache-2.0.

    AsgiApp

    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.

    AsgiApplication

    An application in either calling convention — the current 3.0 single-callable form or the legacy 2.0 double-callable form. A server holds one of these and normalizes it with guarantee_single_callable; the enum is the typed, reflection-free stand-in for asgiref detecting the convention at runtime.

    AsgiVersion

    The asgi sub-dict every scope carries: the ASGI protocol version ("3.0") and the spec_version of the concrete http / websocket / lifespan sub-spec the server implements. Applications negotiate optional behaviour against spec_version with at_least.

    ConformanceReport

    The outcome of the ASGI 3.0 conformance harness: how many round-trip checks ran, how many passed, and the names of any that failures. A clean run has an empty failures. Reusable beyond the test suite — a server or framework built on the seam can call run_conformance to self-verify the wiring.

    Drain

    Why a body drain stopped: the request body finished, the client went away mid-request, or the stream ran out without either. A long-polling or streaming handler cares about the difference — Disconnected means nothing it sends will arrive, so cleanup is all that is left to do.

    Event

    A protocol event flowing between server and application. Replaces ASGI's stringly-typed message dicts with a typed sum covering the http / websocket / lifespan message sets in both directions, including the standard extension messages (server push, path-send, zero-copy send, response trailers, early hints, debug, and WebSocket denial with a full HTTP response).

    EventOrderError

    Why an outbound event stream is malformed. ASGI pins the order an application may emit messages in — a body before the response starts, a second start, a websocket frame before the handshake is accepted, and so on are all protocol violations. validate_events walks a stream and names the first violation it finds; a well-formed stream returns None. A server can run this over its own emissions to catch a buggy app early, and the conformance harness drives a negative table through it.

    Extensions

    The scope's extensions map, modelled as typed capability flags plus the one extension that carries data (tls). A server sets a flag to advertise that it will honour the matching outbound event — an application checks the flag before emitting HttpResponsePush / HttpResponsePathSend / HttpResponseEarlyHint / HttpResponseTrailers / WebSocketHttpResponseStart, exactly as an ASGI app tests "http.response.push" in scope["extensions"].

    Handler

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

    Http2HeaderError

    Why a HEADERS frame does not map onto an HttpScope field-by-field: an HTTP/2 (RFC 7540 §8.1.2.3) or HTTP/3 request carries its request line as four pseudo-headers — :method, :scheme, :authority, :path — interleaved with the ordinary fields, and ASGI reserves no slot for them in scope["headers"]. A conforming server must consume the pseudo-headers into the scope's typed fields and hand the application only the ordinary headers, with host synthesised from :authority. moonasgi owns that lowering so every h2/h2c/h3 transport in the suite (mooncat) produces the same scope from the same frame, and an app never sees a :-prefixed header.

    A malformed pseudo-header set — a missing required one, a duplicate, an unknown :foo, or a pseudo-header after an ordinary field — is rejected with the exact Http2HeaderError, matching RFC 7540's "malformed request" treatment, so the transport can answer RST_STREAM(PROTOCOL_ERROR) instead of forwarding a bad scope.

    HttpScope

    HTTP connection scope (ASGI type == "http"). raw_path / query_string stay Bytes because they are not guaranteed valid UTF-8; header names follow ASGI's lowercased-latin1 convention. asgi carries the version handshake, extensions the advertised server capabilities, and state is per-connection scratch copied from the lifespan state.

    LegacyAsgiApp

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

    LifespanHandler

    A synchronous lifespan application in the startup / shutdown shape, the lifespan analog of the http Handler and the WebSocket WebSocketHandler. on_startup runs once at boot: it seeds the scope's state in place (the map the server then copies onto every request scope — a DB pool handle, a loaded config) and returns Complete or Failed. on_shutdown runs once at teardown to release those resources. Driven in-process by run_lifespan, so boot/teardown logic is testable on every backend without an async runtime — the faithful synchronous core the async server (mooncat) lifts onto the lifespan protocol.

    LifespanReply

    An application's answer to a lifespan phase: Complete when startup or shutdown succeeded, or Failed with a message the server logs and (for startup) aborts the boot on. Mirrors the two replies ASGI allows to each lifespan message — lifespan.startup.complete / .failed and lifespan.shutdown.complete / .failed.

    LifespanScope

    Lifespan scope (ASGI type == "lifespan"): a single run spanning process startup and shutdown, whose state seeds every request scope. Carries its own asgi handshake — lifespan versions independently (sub-spec 2.0), so it does not borrow the http/websocket spec_version.

    Middleware

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

    PushPromise

    A server-side push promise captured by the TestClient: the pushed path and the request headers the server would send for it (http.response.push).

    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.

    Request

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

    Response

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

    Scope

    The connection scope: one value per HTTP request, WebSocket connection, or lifespan run. It carries the immutable connection metadata a server hands to an application, mirroring ASGI 3.0's scope dict as a typed sum.

    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

    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.

    StreamingResponse

    A streamed outbound response: the status line, headers, an ordered list of body chunks each emitted as its own HttpResponseBody, and optional trailing trailers. Models ASGI response streaming (multiple body messages with more_body: true) and the http.response.trailers extension without an async transport. events lowers it to the exact event sequence a server would send. early_hints (the http.response.early_hint extension) are the 103 Early Hints messages emitted ahead of the final response, each element a list of Link header values.

    TestClient

    An in-process application driver — the ASGI TestClient — built on the synchronous @http.run_http_app core, so it needs no socket and runs on every backend. It builds a synthetic http scope, feeds the request body in as HttpRequest events, runs the application, and reassembles the outbound stream into a TestResponse. app is the event-emitting application; the new / from_stream constructors adapt a @http.Handler / @http.StreamHandler.

    TestResponse

    The materialised result of driving an application in-process: the response status and headers, the body reassembled from every HttpResponseBody chunk, the trailing headers gathered from HttpResponseTrailers, any push promises, the pathsend path if the app used the path-send extension, the last zerocopysend handoff, every debug info payload, and the early_hints (each a 103 message's Link values). The captured, sans-transport analog of what a real client would observe.

    TlsExtension

    The tls extension's per-connection data (ASGI TLS extension). Present in a scope's extensions only when the connection is TLS-terminated by the server. Certificates are PEM text; tls_version / cipher_suite are the numeric IANA identifiers, None when the server does not expose them.

    WebSocketHandler

    A synchronous WebSocket application in the connect / receive / disconnect shape, the WebSocket analog of the http Handler. on_connect decides the handshake from the scope; on_receive maps each inbound WsMessage to the frames to send back (it may Close); on_disconnect runs when the client goes away, carrying the close code and the optional close reason (ASGI 2.5). Driven in-process by ws_run and the TestClient, so accept/subprotocol/echo/close logic is testable on every backend without an async socket — the faithful synchronous core the async server (mooncat) lifts onto AsgiApp.

    WebSocketScope

    WebSocket connection scope (ASGI type == "websocket"). scheme is "ws"/"wss"; subprotocols are the client-offered values. Carries the same asgi handshake and extensions capabilities as an http scope.

    WsAccept

    The application's answer to a WebSocketConnect, mirroring the three replies ASGI allows to an opening handshake: Accept it (optionally choosing a subprotocol and adding response headers), Reject it with a bare close code (ASGI closes the handshake with websocket.close), or DenyHttp it with a full HTTP response (the websocket.http.response extension — a real status
    • headers + body instead of a bare close).

    WsMessage

    A message crossing a WebSocket in either direction: a UTF-8 Text frame or a Binary frame. The ergonomic form of WebSocketReceive / WebSocketSendText / WebSocketSendBytes, so a WebSocketHandler reasons over messages instead of raw events.

    WsSend

    An outbound action a WebSocketHandler takes while the connection is open: send a SendText / SendBinary frame, or Close the connection with a code and reason. A Close ends the handler's send stream — the driver stops pulling further inbound messages, mirroring a server that has closed the socket.

    WsTestSession

    The materialised result of driving a WebSocket connection in-process: whether the handshake was accepted, the negotiated subprotocol and accept_headers, every server→client message (messages), whether the server closed and with what close_code / close_reason, and — when the handshake was denied with the websocket.http.response extension — the captured HTTP denial response. The WebSocket analog of TestResponse.

    ZeroCopySend

    A http.response.zerocopysend message captured by the TestClient: the open file descriptor the app handed off, and the optional byte offset / count. The sans-transport analog of the server performing the zero-copy send.

    double_to_single_callable

    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

    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

    Run the ASGI 3.0 conformance harness: a table-driven battery that drives every @spec.Event variant and every Scope field through @http.run_http, ws_run, and the @client.TestClient, asserting round-trip fidelity — a value put in comes back unchanged, and no two distinct events or scope shapes are confused — and a negative table that asserts validate_events rejects every malformed ordering (a body before the response starts, a websocket frame before the handshake, a lifespan shutdown reply before startup, and the rest) with the exact violation. Returns a ConformanceReport naming any check that failed, so the suite (and any downstream server) can assert ok().

    run_http

    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

    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

    The scope-aware sans-transport core: drain the http request body from inbound (accumulating HttpRequest chunks until more_body is false) and hand app the full @spec.HttpScope alongside the assembled body, returning the outbound events app emits. This is the seam a framework binds to — a framework reads what the ergonomic Request drops: root_path (for mounted sub-apps), extensions (to gate a feature on what the server advertises), client/server peers, the asgi handshake, and state (the map a lifespan startup seeds and the server copies onto every request scope). It is the synchronous analog of the ASGI (scope, receive, send) callable for the common drain-then-handle shape; run_http_app is the Request-level wrapper over it. Non-http scopes yield [].

    run_http_scoped_drain

    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

    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

    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

    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

    Lift a synchronous Handler onto the load-bearing @spec.AsgiApp the server binds to. For an http scope it drains the request body — looping receive() and accumulating HttpRequest chunks until more_body is false — assembles a Request, runs the handler, then emits HttpResponseStart followed by a single HttpResponseBody. Non-http scopes (websocket, lifespan) are no-ops: this sugar covers request→response handlers only. Shares its drain, request assembly, and response serialisation with run_http, which tests the same logic without the async transport.

    validate_events

    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

    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

    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

    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

    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

    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

    The general sans-transport WebSocket core: hand the whole inbound event stream and the @spec.WebSocketScope to app and return the outbound events it emits. The escape hatch under ws_run for applications whose control flow is not the connect/receive/disconnect fold — an app is free to inspect every scope field (subprotocols, headers, extensions, state) and emit any sequence. Non-websocket scopes yield []. The WebSocket analog of run_http_app.

    Source Files