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
moon add Lfan-ke/moonasgi@0.7.0
Download zip
Author
Version
0.7.0
License
Apache-2.0
Last updated
17 days ago
Downloads
608
README

#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 type Receive = async () -> Event // pull the next inbound event
pub type Send = async (Event) -> Unit // push an outbound event
pub type AsgiApp = async (Scope, Receive, Send) -> Unit

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

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

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

#TestClient

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

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

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

#WebSocket

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

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

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

#Lifespan

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

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

#Scope-aware core

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

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

#HTTP/2 and HTTP/3

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

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

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

#Conformance

run_conformance() is a table-driven harness that drives every Event variant and every Scope field through run_http / ws_run / TestClient and asserts round-trip fidelity — a value put in comes back unchanged, and no two distinct events or scope shapes are confused. It returns a ConformanceReport naming any failing check, so a downstream server can self-verify the seam wiring:

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

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

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

#Extensions & streaming

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

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

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

#Compliance matrix

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

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

The one spec feature with no direct model is the C-level file object in http.response.zerocopysend: this seam is socket-free by design, so the file descriptor is carried as an Int fd and the actual sendfile is the server adapter's (mooncat) 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.

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 (416 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 — 40 tests, warning-clean across every backend. The async Handler → AsgiApp runtime adapter lands with mooncat, which owns the native async transport.

#License

Apache-2.0.

#
AsgiApp

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

The load-bearing ASGI callable: (scope, receive, send). Every server binds to this shape and every framework in the suite ultimately compiles down to it.

#
AsgiAppInstance

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

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

#
Handler

type Handler = (Request) -> Response

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

#
LegacyAsgiApp

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

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

#
Middleware

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

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

#
Receive

type Receive = async () -> Event

Pull the next inbound event. The async awaitable an application calls to read request body chunks, websocket frames, or lifespan signals.

#
Send

type Send = async (Event) -> Unit

Push an outbound event. The async awaitable an application calls to emit response start/body, websocket frames, or lifespan completion.

#
StreamHandler

type StreamHandler = (Request) -> StreamingResponse

A streaming handler: produces a StreamingResponse (multi-chunk body, optional trailers) instead of a single buffered Response. Driven by run_http_stream and the TestClient.

#
AsgiApplication

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

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

#
AsgiVersion

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

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

#
AsgiVersion::at_least

fn AsgiVersion::at_least(self : AsgiVersion, major~ : Int, minor~ : Int) -> Bool

Strict spec_version negotiation: is this scope's sub-spec at least major.minor? Parses the dotted spec_version (non-digit runs count as 0) and compares numerically, so "2.4".at_least(major=2, minor=3) is true. An application guards a version-gated feature with this instead of string equality.

#
AsgiVersion::default

fn AsgiVersion::default() -> AsgiVersion

The default ASGI handshake: protocol 3.0, sub-spec 2.5 (the current HTTP / WebSocket spec revision, 2024-06-05). An alias of http — the common case.

#
AsgiVersion::http

fn AsgiVersion::http() -> AsgiVersion

The HTTP handshake: protocol 3.0, HTTP sub-spec 2.5. This is the revision that carries state, the extension message set (http.response.trailers / http.response.push / http.response.pathsend / http.response.early_hint), and the raw_path guarantee.

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

#
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)])
WebSocketHttpResponseBody(body~ : Bytes, more_body~ : Bool)
LifespanStartup
LifespanShutdown
LifespanStartupComplete
LifespanStartupFailed(message~ : String)
LifespanShutdownComplete
LifespanShutdownFailed(message~ : String)
} 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
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
} 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_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::none

fn Extensions::none() -> Extensions

No extensions advertised: every capability off, no TLS data. The starting point servers and tests build from with the enable_* / with_tls helpers.

#
Extensions::with_tls

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

Attach tls extension data to a connection scope.

#
Http2HeaderError

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

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

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

#
HttpScope

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

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

#
HttpScope::from_h2_headers

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

Lower an HTTP/2 (or HTTP/3) request's HEADERS block — pseudo-headers and ordinary fields in wire order — into an HttpScope, the way a conforming ASGI server consumes a frame. :method, :scheme and :path become the scope's http_method / scheme / path (with the query string split off :path); the returned headers are the ordinary fields only, with host synthesised at the front from :authority (replacing any host the peer also sent, per RFC 7540 §8.1.2.3). http_version defaults to "2"; pass "3" for an HTTP/3 transport, which shares this pseudo-header contract.

The remaining scope fields (root_path, client, server, extensions, asgi, state) are supplied by the server exactly as for HttpScope::new. Returns Err naming the first violation for a malformed pseudo-header set, so the SEAM never hands a framework a scope with a :-prefixed header or a missing request-line field.

#
HttpScope::new

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

Build an HttpScope with ASGI's usual defaults filled in — http_version "1.1", scheme "http", empty query/headers/state, the default asgi handshake, and no extensions. raw_path defaults to the UTF-8 encoding of path. The ergonomic constructor servers and the TestClient build scopes through, so callers spell only what differs from the common case.

#
LifespanHandler

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

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

#
LifespanHandler::new

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

Build a LifespanHandler. Both phases default to succeeding without doing anything, so a caller overrides only the phase it needs — an app that just wants a startup hook leaves on_shutdown alone.

#
LifespanReply

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

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

#
LifespanScope

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

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

#
LifespanScope::new

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

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

#
PushPromise

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

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

#
PushPromise::header

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

First push-request header matching name.

#
Request

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

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

#
Request::header

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

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

#
Response

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

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

#
Response::events

fn Response::events(self : Response) -> Array[Event]

Serialise a Response into the outbound event pair a server sends: an HttpResponseStart carrying status and headers (no trailers), then a single HttpResponseBody with the whole body and more_body: false. The public Response::events for callers that want the lowered form directly.

#
Response::header

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

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

#
Response::json

fn Response::json(status? : Int, value : Json) -> Response

An application/json response whose body is the UTF-8 encoding of value serialised with Json::stringify. The ergonomic constructor for a JSON reply.

#
Response::new

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

Build a response from raw bytes.

#
Response::text

fn Response::text(status? : Int, body : String) -> Response

A text/plain; charset=utf-8 response whose body is the UTF-8 encoding of body. The ergonomic constructor for the common string reply.

#
Scope

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

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

#
StreamingResponse

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

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

#
StreamingResponse::events

Lower a streamed response to the outbound events a server sends: one HttpResponseEarlyHint per early-hint message (before the response starts), then an HttpResponseStart (with trailers set when any are present), then one HttpResponseBody per chunk — more_body: true on all but the last — and, if there are trailers, a terminating HttpResponseTrailers. An empty chunks still yields a single empty final body, so the stream is always well-formed.

#
StreamingResponse::new

fn StreamingResponse::new(chunks~ : Array[Bytes], status? : Int, headers? : Array[(String, String)], trailers? : Array[(String, String)], early_hints? : Array[Array[String]]) -> StreamingResponse

Build a StreamingResponse. status defaults to 200, headers, trailers and early_hints to empty; chunks is the ordered body, each element becoming one HttpResponseBody frame.

#
TestClient

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

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

#
TestClient::delete

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

DELETE path.

#
TestClient::from_app

fn TestClient::from_app(app : (Request) -> Array[Event], root_path? : String, base_headers? : Array[(String, String)], client? : (String, Int)?, server? : (String, Int?)?, extensions? : Extensions) -> TestClient

Build a TestClient from a raw event-emitting application (the shape run_http_app drives). Defaults mirror a typical test harness: empty root_path, no base headers, a ("testclient", 50000) client peer, a ("testserver", 80) server, and no advertised extensions.

#
TestClient::from_stream

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

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

#
TestClient::get

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

GET path.

#
TestClient::head

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

HEAD path.

#
TestClient::new

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

Build a TestClient from a unary Handler.

#
TestClient::patch

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

PATCH path with body.

#
TestClient::post

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

POST path with body.

#
TestClient::put

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

PUT path with body.

#
TestClient::request

fn TestClient::request(self : TestClient, http_method~ : String, path~ : String, headers? : Array[(String, String)], body? : Bytes, http_version? : String, chunks? : Array[Bytes]) -> TestResponse

Drive one request through the application and capture the response. Splits path into path + query string, merges the client's base_headers before the per-request headers, builds an http scope (carrying the client's root_path / peers / advertised extensions), feeds the body in, runs the app, and reassembles the outbound stream. Pass chunks to stream the request body as several HttpRequest events.

#
TestClient::websocket

fn TestClient::websocket(self : TestClient, path~ : String, handler~ : WebSocketHandler, send? : Array[WsMessage], headers? : Array[(String, String)], subprotocols? : Array[String], disconnect? : Int?) -> WsTestSession

Drive a WebSocketHandler through a full connection in-process and capture the result. Opens a WebSocketConnect, feeds each send message in as a WebSocketReceive, then (unless disconnect is None) a WebSocketDisconnect with the given close code, runs the handler through the synchronous ws_run core, and reassembles the server's outbound frames into a WsTestSession. No socket — testable on every backend, the WebSocket counterpart of request.

#
TestClient::websocket_app

fn TestClient::websocket_app(self : TestClient, path~ : String, app~ : (WebSocketScope, Array[Event]) -> Array[Event], send? : Array[WsMessage], headers? : Array[(String, String)], subprotocols? : Array[String], disconnect? : Int?) -> WsTestSession

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

#
TestResponse

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

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

#
TestResponse::header

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

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

#
TestResponse::json

fn TestResponse::json(self : TestResponse) -> Json raise

The response body parsed as JSON. Raises @json.ParseError if the body is not valid JSON, mirroring a real client's .json().

#
TestResponse::text

fn TestResponse::text(self : TestResponse) -> String

The response body decoded as UTF-8 (lossy: invalid sequences become the replacement character), for asserting on text responses.

#
TestResponse::trailer

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

First trailing header matching name.

#
TlsExtension

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

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

#
WebSocketHandler

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

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

#
WebSocketHandler::echo

fn WebSocketHandler::echo(subprotocol? : String?) -> WebSocketHandler

An echo handler: accept the handshake (optionally negotiating subprotocol), then send every received message straight back — text as text, binary as binary. The canonical WebSocket smoke test.

#
WebSocketHandler::new

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

Build a WebSocketHandler. on_connect defaults to accepting the handshake with no chosen subprotocol and no extra headers; on_receive and on_disconnect default to doing nothing. Callers override only the phases they care about.

#
WebSocketScope

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

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

#
WebSocketScope::new

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

Build a WebSocketScope with ASGI's usual defaults filled in — scheme "ws", empty query/headers/subprotocols/state, the default asgi handshake, and no extensions. raw_path defaults to the UTF-8 encoding of path.

#
WsAccept

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

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

#
WsMessage

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

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

#
WsSend

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

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

#
WsTestSession

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

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

#
WsTestSession::header

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

First accept-response header matching name.

#
WsTestSession::texts

fn WsTestSession::texts(self : WsTestSession) -> Array[String]

The server→client messages that were text frames, decoded to their strings (binary frames are skipped). The ergonomic assertion target for an echo/chat exchange.

#
ZeroCopySend

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

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

#
compose

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

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

#
double_to_single_callable

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

Wrap a legacy 2.0 double-callable app as a 3.0 single-callable AsgiApp (asgiref's double_to_single_callable): call the first callable with the scope to get the instance, then drive it with receive / send.

#
guarantee_single_callable

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

Normalize any application to a single-callable AsgiApp (asgiref's guarantee_single_callable): a 3.0 app passes straight through, a legacy 2.0 app is wrapped. A server calls this once and then only ever drives single-callables.

#
headers_from_wire

fn headers_from_wire(wire : Array[(Bytes, Bytes)]) -> Array[(String, String)]

Decode a wire header list (raw ASGI byte-string pairs, as they arrive off an HTTP/1.1 or HPACK frame) into the (String, String) pairs the seam uses.

#
headers_to_wire

fn headers_to_wire(headers : Array[(String, String)]) -> Array[(Bytes, Bytes)]

Encode the seam's header list back to raw ASGI byte-string pairs for the wire. The exact inverse of headers_from_wire, byte for byte.

#
latin1_decode

fn latin1_decode(bytes : Bytes) -> String

Decode a raw byte string to a latin-1 String: each byte becomes the character with that code point. Total — every byte 0x00..0xFF is a valid code point.

#
latin1_encode

fn latin1_encode(s : String) -> Bytes

Encode a latin-1 String back to its byte string: each character's low byte. The inverse of latin1_decode. Header field values are always latin-1 (code points 0..255) by HTTP's own rules, so the low byte is the whole character.

#
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_stream

fn run_http_stream(handler : (Request) -> StreamingResponse, scope : Scope, inbound : Array[Event]) -> Array[Event]

Drive a streaming StreamHandler over an inbound event sequence, returning the full outbound stream — HttpResponseStart, one HttpResponseBody per chunk (more_body: true on all but the last), and a trailing HttpResponseTrailers when the response carries trailers. The streaming counterpart of run_http.

#
run_legacy

fn run_legacy(app : (Scope) -> ((Array[Event]) -> Array[Event]), scope : Scope, inbound : Array[Event]) -> Array[Event]

The synchronous core of the legacy two-call convention, testable without an async runtime the way run_http is for to_asgi: the first callable takes the scope and returns an instance that folds the inbound event stream into the outbound one. double_to_single_callable is the async lift of exactly this two-step shape, so a green run_legacy covers the convention's semantics that the async wrapper relies on.

#
run_lifespan

fn run_lifespan(handler : LifespanHandler, scope : Scope, inbound : Array[Event]) -> Array[Event]

Drive a LifespanHandler over a materialised inbound event stream, folding it into the outbound replies a server would send. LifespanStartup runs on_startup and emits LifespanStartupComplete or LifespanStartupFailed; a failed startup ends the run, since ASGI has the server abort the boot and never send lifespan.shutdown. LifespanShutdown runs on_shutdown and emits the matching shutdown reply. The handler mutates scope.state in place during startup, exactly as an ASGI app populates scope["state"]. This is the synchronous lifespan core mirroring run_http / ws_run; a non-lifespan scope yields [].

#
to_asgi

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

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

#
validate_events

fn validate_events(scope : Scope, events : Array[Event]) -> EventOrderError?

Validate an outbound event stream against the ordering rules for its scope, dispatching to the http / websocket / lifespan validator. The public entry a server calls to check an application's emissions before writing them to the wire.

#
validate_http_response

fn validate_http_response(events : Array[Event]) -> EventOrderError?

Validate the outbound event stream an application emits for an http request, against ASGI's http response ordering: at most one HttpResponseStart, no body or pathsend/zerocopysend before it, early hints only ahead of it, a body stream that terminates once (more_body: false), trailers only when the start promised them and only after the body completes, and nothing after the response is fully sent. HttpResponseDebug carries no protocol meaning and is accepted anywhere; HttpResponsePush is accepted before or during the response. Returns the first violation, or None for a well-formed complete response.

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