moonasgi

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

    asgi
    moonbit
    web
    server-interface
    async
    http
    Download zip
    Version
    0.10.0
    License
    Apache-2.0
    Last updated
    6 days ago
    Downloads
    249

    #moonasgi

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

    Check and Test License mooncakes

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

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

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

    #The contract

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

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

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

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

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

    #TestClient

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

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

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

    #WebSocket

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

    let client = TestClient::new(handler)
    let session = client.websocket(
    path="/chat",
    handler=WebSocketHandler::echo(subprotocol="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.

    #Consumed by

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

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

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

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

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

    #Design

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

    #Status

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

    #License

    Apache-2.0.

    AsgiApp

    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