moonbitstack/moonasgi/spec does not have a README file

    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.

    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.

    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.

    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::equal

    fn AsgiVersion::equal(AsgiVersion, AsgiVersion) -> Bool

    AsgiVersion::http

    fn AsgiVersion::http() -> AsgiVersion

    The HTTP handshake: protocol 3.0, HTTP sub-spec 2.5 — the www sub-spec's current revision, whose own history runs 2.1 websocket accept headers, 2.2 a None server port, 2.3 a websocket close reason, 2.4 send() raising on a closed connection, 2.5 a websocket disconnect reason. Of those, 2.4 is the one this seam cannot express: see the note in the README.

    AsgiVersion::lifespan

    fn AsgiVersion::lifespan() -> AsgiVersion

    The Lifespan handshake: protocol 3.0, lifespan sub-spec 2.0 (the revision that carries state). Lifespan versions independently of http/websocket, so a lifespan scope must not borrow the http spec_version.

    AsgiVersion::not_equal

    fn AsgiVersion::not_equal(x : AsgiVersion, y : AsgiVersion) -> Bool

    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.

    Event

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

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

    Event::equal

    fn Event::equal(Event, Event) -> Bool

    Event::not_equal

    fn Event::not_equal(x : Event, y : Event) -> Bool

    Extensions

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

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

    Extensions::enable

    fn Extensions::enable(self : Extensions, name : String, value? : Json) -> Extensions

    Advertise an extension this seam does not name, with whatever value the server wants to hand the application (the spec's own http.fullflush example carries an empty object).

    Extensions::enable_debug

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

    Advertise the debug extension (http.response.debug). Per the ASGI spec this is for testing only and production servers should not implement it.

    Extensions::enable_early_hint

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

    Advertise the early-hints extension (http.response.early_hint): the server will forward HttpResponseEarlyHint messages as 103 Early Hints informational responses ahead of the final status.

    Extensions::enable_pathsend

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

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

    Extensions::enable_push

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

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

    Extensions::enable_trailers

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

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

    Extensions::enable_websocket_http_response

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

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

    Extensions::enable_zerocopysend

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

    Advertise the zero-copy-send extension (http.response.zerocopysend): the server will send the contents of an open file descriptor with zero copies.

    Extensions::equal

    fn Extensions::equal(Extensions, Extensions) -> Bool

    Extensions::get

    fn Extensions::get(self : Extensions, name : String) -> Json?

    What the server advertised for name, or None if it did not. Only the extensions this seam has no field for; the named ones are the Bools above.

    Extensions::none

    fn Extensions::none() -> Extensions

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

    Extensions::not_equal

    fn Extensions::not_equal(x : Extensions, y : Extensions) -> Bool

    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.

    Http2HeaderError::equal

    Http2HeaderError::not_equal

    fn Http2HeaderError::not_equal(x : Http2HeaderError, y : Http2HeaderError) -> Bool

    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.

    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.

    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.

    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.

    TlsExtension::equal

    TlsExtension::not_equal

    fn TlsExtension::not_equal(x : TlsExtension, y : TlsExtension) -> Bool

    WebSocketScope

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

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

    WebSocketScope::new

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

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

    WebSocketScope::select_subprotocol

    fn WebSocketScope::select_subprotocol(self : WebSocketScope, supported : Array[String]) -> String?

    Choose the subprotocol to accept from those the client offered (RFC 6455 §4.1): the first of the client's offered subprotocols, in the client's own order of preference, that the server also lists in supported; None when the two share none (the connection then proceeds with no subprotocol). The result is what the app hands to WebSocketAccept(subprotocol=..).

    double_to_single_callable

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

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

    guarantee_single_callable

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

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

    headers_from_wire

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

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

    headers_to_wire

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

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

    latin1_decode

    fn latin1_decode(bytes : Bytes) -> String

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

    latin1_encode

    fn latin1_encode(s : String) -> Bytes

    Encode a String back to a header byte string. The inverse of latin1_decode for anything that came from the wire, since HTTP header values are latin-1 by the protocol's own rules.

    A character above U+00FF never came off a wire — it was put there by a framework setting, say, a content-disposition filename — and truncating it to its low byte would silently corrupt it. Those are written as their UTF-8 bytes instead, which is what real servers emit and what RFC 6266 §4.3 expects a recipient to decode.

    percent_decode

    fn percent_decode(target : String) -> Bytes

    Percent-decode a request target's path into the characters ASGI's path carries, leaving the undecoded bytes for raw_path. %2F inside a segment therefore reaches the application as a slash in path, which is why a router that cares about segment boundaries reads raw_path.

    A stray % or a truncated escape is passed through as written rather than raising: the target is attacker-controlled, and a server that refuses to build a scope cannot answer 400 either.

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

    split_target

    fn split_target(target : String) -> (String, String)

    Split a request target into its path and raw query string on the first ?; "/a/b?x=1&y=2" → ("/a/b", "x=1&y=2"), "/a/b" → ("/a/b", "").