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.

    Closed

    pub(all) suberror Closed {
    Closed(String)
    } derive(Eq,
    Debug
    )

    A message sent on a connection that has already closed (HTTP and WebSocket spec 2.4): the client went away, or the response or the WebSocket was already finished. A server raises it from send. An application that keeps sending after a disconnect catches it and stops; a server need not log it as a failure. The text says which connection, for whoever reads the log.

    Closed::equal

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

    Closed::not_equal

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

    Closed::to_repr

    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 way to name which convention an application is written in.

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

    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,
    Debug
    )

    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).
    impl ToJson for Event

    Event::equal

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

    Event::kind

    fn Event::kind(self : Event) -> String

    The specification's name for a message: http.request, websocket.accept, lifespan.shutdown.failed. The type key of the dict Json(event) writes, on its own — a log line, a metrics label or a match on the message family without building the whole dict.

    An Other message is named by the type the two sides agreed on, since that is what it is.

    Event::not_equal

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

    Event::to_json

    Event::to_repr

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

    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 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 — and raw_path is optional, because the specification lets a server leave it out; a framework that needs the undecoded target reads it, and falls back to encoding path. 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.
    impl ToJson for HttpScope

    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 is left None — the specification's own default, since a server that did not keep the undecoded target has nothing to report. The ergonomic constructor servers and the TestClient build scopes through, so callers spell only what differs from the common case.

    HttpScope::to_json

    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

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

    LifespanScope::to_json

    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.
    impl ToJson for Scope

    Scope::to_json

    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

    TlsExtension::to_json

    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 is left None unless the handshake request's target was kept.

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

    WebSocketScope::to_json

    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: 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: a 3.0 app passes straight through, and 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", "").