Sign in

    mooncat

    mooncat — a native ASGI 3.0 server for MoonBit (← uvicorn), built on moonbitlang/async and the moonasgi SEAM. HTTP/1.1, HTTP/2, WebSocket and HTTP/3 over moonquic.

    asgi
    server
    uvicorn
    http
    websocket
    moonbit
    native
    Download zip
    Version
    0.11.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    214

    #mooncat

    A native ASGI 3.0 server for MoonBit — ← uvicorn.

    Check and Test License mooncakes

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

    mooncat runs a moonasgi application over a real network socket. It sits between moonbitlang/async's native HTTP transport and the ASGI SEAM: it accepts connections, turns each request into a Scope + Receive + Send, and drives your app — exactly the role uvicorn plays for Python.

    flowchart LR net["moonbitlang/async<br/>(sockets · TLS · HTTP/1.1)"] --> cat["**mooncat**<br/>accept loop"] cat -->|"Scope / Receive / Send"| asgi(["moonasgi SEAM"]) asgi --> app["your app<br/>(moonapi, …)"]

    #Quickstart

    // An ASGI app: respond 200 with a text body.
    let app : @moonasgi.AsgiApp = (_scope, _receive, send) => {
    send(@moonasgi.Event::HttpResponseStart(
    status=200, headers=[("content-type", "text/plain")]))
    send(@moonasgi.Event::HttpResponseBody(
    body=b"Hello from mooncat!", more_body=false))
    }

    // Serve it (inside an async context / task group):
    @mooncat.serve(app, host="127.0.0.1", port=12000)

    serve blocks in a keep-alive accept loop until its task is cancelled. Each request is bridged faithfully: the method/path/query/headers become an http Scope, the request body streams through Receive, and HttpResponseStart / HttpResponseBody events are written back via the connection.

    #HTTPS / TLS

    serve_tls serves the same ASGI app over TLS (← uvicorn's --ssl-certfile / --ssl-keyfile):

    @mooncat.serve_tls(app,
    certificate_file="certs/cert.pem", // PEM cert (OpenSSL platforms)
    private_key_file="certs/key.pem", // PEM key (OpenSSL platforms)
    pfx_file="certs/dev.pfx", // PKCS#12 (Windows / SChannel)
    port=12000)

    Each accepted connection completes a real TLS handshake (@tls.Tls), then a self-built, transport-agnostic HTTP/1.1 codec — request-line + header parser and a response writer that frames single-shot replies with Content-Length and streamed replies with Transfer-Encoding: chunked — drives the moonasgi app over the encrypted stream, with keep-alive and the lifespan protocol intact. Proven end-to-end by a real @http TLS client doing GET over https:// and asserting 200 + body in CI. Regenerate the throwaway localhost test cert with scripts/gen_test_cert.sh.

    Design boundaries (honest, not stubs):

    • The certificate is supplied as PEM on OpenSSL platforms (Linux/macOS) and as a PKCS#12 .pfx on Windows (SChannel), because that is what each moonbitlang/async TLS backend accepts; serve_tls selects the right form at compile time.
    • moonbitlang/async exposes its server-side TLS constructor as #internal ("for internal testing only") — it is the only such entry point, and the async suite itself serves HTTPS through it — so mooncat opts into that one alert (warnings = "-alert_internal" in moon.pkg) and will migrate the moment a public API lands.
    • WebSocket-over-TLS (wss://) is not bridged: the async websocket upgrade needs an @http.ServerConnection, which is welded to @socket.Tcp and cannot wrap a @tls.Tls stream. Plaintext serve keeps full WebSocket support; this is a transport-capability boundary, not a behavioural choice.

    #Process model

    serve_graceful runs the app under uvicorn's process model — a graceful shutdown and a --reload file watcher:

    let handle = @mooncat.ShutdownHandle::new()
    g.spawn(() => @mooncat.serve_graceful(app, @mooncat.Config::new(port=12000), handle~))
    // … later, from anywhere:
    handle.shutdown() // stop accepting → drain in-flight → lifespan shutdown → close

    handle.shutdown() blocks until the port is free again. The sequence is fixed and verified end-to-end in CI: a slow request that is still in flight when shutdown fires is allowed to finish (the client still sees 200) before lifespan shutdown runs, and the listener is closed only afterwards — a real socket integration test pins the ordering, and a mutation test (running lifespan shutdown before the drain) turns it red. A shutdown can also come from a signal: wire @signal.set_global_cancellation_signals and mooncat runs lifespan shutdown + close under protect_from_cancel on the way down.

    serve_graceful drives each connection through the same request path serve does — it builds a real @http.ServerConnection over the accepted socket and reads requests off it directly, rather than owning a private codec. So WebSocket upgrades work under graceful serve too: a ws:// route gets the full frame↔Event bridge, verified in CI by a real @websocket client doing a text + binary round-trip against a serve_graceful server.

    reload_watch(dir, on_reload) watches a source tree with @fs.Watcher and fires on_reload on each change — hook it to a ShutdownHandle to turn a file save into a graceful restart.

    Multi-worker boundary (honest, not a stub): uvicorn's --workers forks N OS processes that share the port via SO_REUSEPORT. moonbitlang/async exposes neither SO_REUSEPORT nor a fork primitive, and its event loop permits only one outstanding accept per listener (a second concurrent accept on the same handle aborts), so N in-process acceptor tasks aren't expressible. mooncat serves from one acceptor that spawns a concurrent handler per connection — the same concurrency a single uvicorn worker gives on its one event loop. Multi-process fan-out lands when the async layer exposes SO_REUSEPORT or fork.

    #HTTP/2 (h2c)

    serve_h2c serves the same ASGI app over HTTP/2 cleartext — the prior-knowledge, no-TLS HTTP/2 profile a client reaches with curl --http2-prior-knowledge or a gRPC client:

    @mooncat.serve_h2c(app, host="127.0.0.1", port=12000)

    The transport is moonrpc's self-built HTTP/2 stack — the same RFC 7540 frame layer and RFC 7541 HPACK engine that carries real gRPC there. mooncat binds it to ASGI: it reads the client connection preface + SETTINGS, HPACK-decodes each request's HEADERS block into an Http Scope (the :method / :path / :scheme / :authority pseudo-headers plus the ordinary headers), streams request DATA to the app as the Receive body, and encodes the response — a HEADERS frame with the :status pseudo-header, then DATA — back over the connection. Requests multiplex on their own stream ids, and the response DATA is split to the peer's maximum frame size and clamped to the connection- and stream-level send windows, resuming on WINDOW_UPDATE (RFC 7540 §6.9).

    Proven end to end in CI by a real HTTP/2 (h2c) client built on @socket.Tcp and the same frame + HPACK codecs: it GETs a route and reads 200 + body, POSTs a body the app echoes, drives two multiplexed streams on one connection, and — with a deliberately small advertised window — checks the server clamps each DATA frame to the granted window. The same greet chain runs over h2c too: a real moonapi app answers a genuine HTTP/2 GET, the router resolving the path decoded out of the HPACK block.

    h2c rather than h2-over-TLS because the moonbitlang/async TLS layer exposes no ALPN, so the protocol can't be negotiated on a TLS connection yet; h2c is the direct, ALPN-free path. HTTP/3 (QUIC) is a separate later self-build.

    #Status

    v0 — HTTP/1.1 request → ASGI Scope/Receive/Send → response is working and verified by a real socket round-trip in CI (a server task answers a live @http.get). Landed and CI-verified alongside it:

    • the lifespan protocol — the app runs once under a Lifespan scope, startup is driven before the listener binds and shutdown on the way out, even under cancellation;
    • the full WebSocket frame↔Event bridge — a Connection: upgrade + Upgrade: websocket handshake becomes a websocket Scope driven through the moonasgi SEAM: receive() emits websocket.connect then real inbound text/binary frames as WebSocketReceive (streamed through the Message-as-Reader, so fragments reassemble) and a peer close as WebSocketDisconnect(code); send() turns WebSocketAccept into the deferred 101 handshake, WebSocketSendText/WebSocketSendBytes into message frames, and WebSocketClose(code, reason) into a close frame. Rejecting before accept answers 403; ping/pong are auto-handled at the protocol layer (as in uvicorn). The receive side enforces RFC 6455 rather than trusting the peer: a set RSV bit, a reserved opcode, an unmasked client frame and a malformed control frame each fail the connection with 1002, a payload past ws.max_size with 1009 — checked before the payload is read, so a 64-bit length is never truncated into a short allocation — invalid UTF-8 in a text message with 1007, and an inbound close is echoed per §5.5.1. Proven by a real @websocket client doing a full text + binary round-trip and a clean close in CI;
    • a Config in the shape of uvicorn's, and honoured rather than merely recorded: dual_stack, reuse_addr, per-server response headers, max_connections, allow_failure, timeout_keep_alive (an idle keep-alive connection is dropped, on both keep-alive loops), limit_max_requests (the server stops itself, on the plain acceptor and the graceful one), limit_concurrency (uvicorn's 503 rather than a queue), max_head_size (uvicorn's h11_max_incomplete_event_size), date_header, proxy_headers / forwarded_allow_ips, and ws.max_size. backlog is the one field that does nothing: @socket.TcpServer calls listen() itself with a fixed depth and exposes no way to change it. Chunked request/response framing is handled by the codec.

    mooncat also hosts a real moonapi app end to end: a CI integration test builds a moonapi App with a typed /greet/:name route, serves it through serve, and a real @http client GETs it and asserts the JSON body and 200 (and a 404 on a route miss). That's the server half of the suite's greet chain — the two repos cooperating over a live socket, not just compiling together.

    HTTPS/TLS serving, the graceful-shutdown + --reload process model, and HTTP/2 (h2c) serving landed too — see the sections above. Roadmap, transliterated from uvicorn feature-by-feature: subprotocol echo into the 101 response on the async-transport path (awaits a transport hook), further TLS detail (ciphers/mTLS), h2 over TLS once the async layer exposes ALPN, multi-process prefork once it exposes SO_REUSEPORT or fork, backlog once TcpServer takes one, and the remaining WebSocket knobs — ws-ping-interval / ws-ping-timeout / ws-max-queue need a reader that can be interrupted between messages and resumed, and ws-per-message-deflate needs a raw DEFLATE codec the async gzip package does not expose.

    #Native only

    The moonbitlang/async HTTP server is native-only (Linux/macOS) — there is no JS server backend — so mooncat builds and runs on the native target. CI covers ubuntu + macos.

    #License

    Apache-2.0.

    #What this server does not implement

    The algorithms are not here. AES, GCM, SHA-1, SHA-256, HMAC, HKDF, X25519, ECDSA and the DER codec live in mooncrypt; X.509 lives in mooncred; the levels and the sink behind the log lines are moonlog's; base64 is moonbase's.

    What stays is what belongs to a server: the TLS 1.3 and QUIC state machines, the HTTP/1.1, HTTP/2 and HTTP/3 codecs, QPACK, the WebSocket framing, the process model, and crypto.mbt — a file with no algorithm in it, binding those primitives to the one suite these protocols name (AES-128-GCM with SHA-256, P-256, X25519) so the protocol code says what it is doing rather than restating the suite on every line.

    The protocol state machines are slated to move to moonnet in turn; until then they are here.

    #Listening

    The default port is 12000, for HTTP, HTTPS, WebSocket and h2c alike — one listener tells them apart, so TLS gets no shadow port of its own. QUIC takes the same number on UDP, which is a different port space. It is a default, not a fixture: Config::new(port=...) moves it.

    Http3ConnError

    pub suberror Http3ConnError {
    Http3ConnError(String)
    }

    A connection-level HTTP/3 error (RFC 9114 §8): a broken control stream or a stream a peer must not have opened.

    Http3FrameError

    pub suberror Http3FrameError {
    Http3FrameError(String)
    }

    A malformed HTTP/3 frame (a protocol error, distinct from an incomplete read).

    LifespanError

    pub suberror LifespanError {
    LifespanError(String)
    }

    Raised when an application reports lifespan.startup.failed or lifespan.shutdown.failed, carrying the failure message the app supplied.

    QpackError

    pub suberror QpackError {
    QpackError(String)
    }

    A malformed or unsupported QPACK field section.

    WsError

    pub suberror WsError {
    WsError(Int, String)
    }

    A WebSocket protocol violation, carrying the RFC 6455 §7.4.1 status code the connection must be failed with (§7.1.7) and a diagnostic. Distinct from the reader's end-of-stream, which is not a violation and closes the connection without a status.

    Config

    pub(all) struct Config {
    host : String
    port : Int
    backlog : Int
    dual_stack : Bool
    reuse_addr : Bool
    server_headers : Map[String, String]
    max_connections : Int?
    allow_failure : Bool
    graceful_timeout : Int?
    root_path : String
    logger : Logger
    timeout_keep_alive : Int
    limit_max_requests : Int?
    limit_concurrency : Int?
    max_head_size : Int
    date_header : Bool
    proxy_headers : Bool
    forwarded_allow_ips : Array[String]
    ws : WsConfig
    }

    Server configuration (← uvicorn Config): the bind address, the listen backlog, and the HTTP/1.1 transport knobs the moonbitlang/async server exposes. Constructed once and handed to serve_config.

    backlog mirrors uvicorn's listen(2) backlog. The current moonbitlang/async transport does not expose a backlog setter on its TCP listener — TcpServer calls listen() itself with a fixed depth and offers no way to re-listen — so the value is recorded on the config for parity and future use but is not applied to the socket. This is a transport-capability gap, not a behavioural choice, and it is the one field here that does nothing.

    The remaining fields map one-to-one onto knobs mooncat does honour: dual_stack / reuse_addr on the listening socket, server_headers stamped onto every response (uvicorn's Server: header lives here), max_connections for the parallel-client ceiling, and allow_failure for whether a handler error tears the whole server down.

    Config::bind

    fn Config::bind(self : Config) -> String

    The host:port string used to resolve the listen address.

    Config::new

    fn Config::new(host? : String, port? : Int, backlog? : Int, dual_stack? : Bool, reuse_addr? : Bool, server_headers? : Map[String, String], max_connections? : Int, allow_failure? : Bool, graceful_timeout? : Int, root_path? : String, logger? : Logger, timeout_keep_alive? : Int, limit_max_requests? : Int, limit_concurrency? : Int, max_head_size? : Int, date_header? : Bool, proxy_headers? : Bool, forwarded_allow_ips? : Array[String], ws? : WsConfig) -> Config

    Build a Config, defaulting to uvicorn's own defaults: host 127.0.0.1, port 12000, backlog 2048, a 5-second idle keep-alive timeout, a 16 KiB request-head cap, the Date header on, proxy headers trusted from 127.0.0.1, and no request-count or concurrency limit. Transport knobs default to the async server's own defaults: reuse_addr on (uvicorn sets SO_REUSEADDR), single-stack binding, no extra response headers, an unbounded connection ceiling, and allow_failure on so a single failing handler never crashes the listener.

    graceful_timeout bounds how long serve_graceful waits for in-flight requests to drain before it runs lifespan shutdown anyway (← uvicorn's timeout_graceful_shutdown); None waits until the last request finishes, as uvicorn does by default.

    Http3Conn

    pub struct Http3Conn {
    role : Http3Role
    local_settings : Http3Settings
    peer_settings : Http3Settings?
    control_seen : Bool
    }

    One endpoint of an HTTP/3 connection: its role, the settings it advertises, and — once the peer opens its control stream — the peer's settings.

    Http3Conn::accept_uni_stream

    fn Http3Conn::accept_uni_stream(self : Http3Conn, bytes : Bytes) -> Http3UniEvent raise

    Accept a peer's unidirectional stream from its opening bytes: classify it and, for the control stream, decode and keep the peer's SETTINGS. A second control stream, a control stream not opening with SETTINGS, and a server receiving a push stream are all connection errors (RFC 9114 §6.2).

    Http3Conn::new

    fn Http3Conn::new(role : Http3Role, local_settings : Http3Settings) -> Http3Conn

    A connection that will advertise local_settings.

    Http3Conn::open_control_stream

    fn Http3Conn::open_control_stream(self : Http3Conn) -> Bytes

    The bytes to write when opening our control stream: the control-stream type then our SETTINGS frame, which a control stream must send first (RFC 9114 §6.2.1).

    Http3Conn::peer_settings

    fn Http3Conn::peer_settings(self : Http3Conn) -> Http3Settings?

    The peer's advertised settings, once its control stream has been accepted.

    Http3Conn::settings_established

    fn Http3Conn::settings_established(self : Http3Conn) -> Bool

    Whether the peer's control-stream SETTINGS have been received (connection setup complete).

    Http3Frame

    pub(all) enum Http3Frame {
    Data(Bytes)
    Headers(Bytes)
    CancelPush(UInt64)
    Settings(Array[(UInt64, UInt64)])
    PushPromise(UInt64, Bytes)
    GoAway(UInt64)
    MaxPushId(UInt64)
    Reserved(UInt64, Bytes)
    ReservedHttp2(UInt64)
    } derive(Eq,
    Debug
    )

    A decoded HTTP/3 frame. Data/Headers/PushPromise carry their opaque payloads; Settings carries the decoded (identifier, value) pairs. Reserved is any unknown or grease frame type (RFC 9114 §7.2.8 / §9), which a receiver ignores; ReservedHttp2 is one of the frame types reused from HTTP/2 (0x02, 0x06, 0x08, 0x09), which RFC 9114 §7.2 requires be treated as a connection error.

    Http3Frame::equal

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

    Http3Frame::not_equal

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

    Http3Request

    pub(all) struct Http3Request {
    meth : String
    scheme : String
    authority : String
    path : String
    headers : Array[(String, String)]
    body : Bytes
    }

    A decoded HTTP/3 request: the pseudo-headers pulled out, the remaining header fields, and the body.

    Http3Response

    pub(all) struct Http3Response {
    status : String
    headers : Array[(String, String)]
    body : Bytes
    }

    An HTTP/3 response: the status, the header fields, and the body.

    Http3Response::encode

    fn Http3Response::encode(self : Http3Response) -> Bytes

    Encode the response as an HTTP/3 response stream (a HEADERS frame with :status, then DATA).

    Http3Role

    pub(all) enum Http3Role {
    ClientSide
    ServerSide
    } derive(Eq,
    Debug
    )

    Which side of the connection an endpoint is (RFC 9114 §3.1): a server must not receive a push stream, so the role decides whether one is a protocol violation.

    Http3Role::equal

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

    Http3Role::not_equal

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

    Http3Settings

    pub(all) struct Http3Settings {
    qpack_max_table_capacity : UInt64
    max_field_section_size : UInt64
    qpack_blocked_streams : UInt64
    } derive(Eq,
    Debug
    )

    An endpoint's HTTP/3 settings.

    Http3Settings::equal

    Http3Settings::from_pairs

    fn Http3Settings::from_pairs(pairs : Array[(UInt64, UInt64)]) -> Http3Settings

    Read settings from received (identifier, value) pairs, taking the value for each known identifier (0 when absent) and ignoring unknown ones (RFC 9114 §7.2.4.1 grease tolerance).

    Http3Settings::new

    fn Http3Settings::new(qpack_max_table_capacity : UInt64, max_field_section_size : UInt64, qpack_blocked_streams : UInt64) -> Http3Settings

    Settings with the given QPACK table capacity, maximum field section size, and blocked-stream limit.

    Http3Settings::not_equal

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

    Http3Settings::to_pairs

    fn Http3Settings::to_pairs(self : Http3Settings) -> Array[(UInt64, UInt64)]

    The (identifier, value) pairs to carry in a SETTINGS frame.

    Http3StreamCtx

    pub(all) enum Http3StreamCtx {
    ControlCtx
    RequestCtx
    PushCtx
    } derive(Eq,
    Debug
    )

    The stream a frame was received on, for the purpose of frame-placement rules (RFC 9114 §6.2).

    Http3StreamCtx::equal

    Http3StreamCtx::not_equal

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

    Http3UniEvent

    pub(all) enum Http3UniEvent {
    ControlEstablished(Http3Settings)
    QpackEncoder
    QpackDecoder
    Push(UInt64)
    Reserved(UInt64)
    } derive(Eq,
    Debug
    )

    The result of accepting a peer's unidirectional stream: the control stream carries the peer's SETTINGS, the QPACK streams carry table instructions, a push stream carries its id, and an unrecognized type is reserved/greased.

    Http3UniEvent::equal

    Http3UniEvent::not_equal

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

    Http3UniStream

    pub(all) enum Http3UniStream {
    ControlStream
    PushStream(UInt64)
    QpackEncoderStream
    QpackDecoderStream
    ReservedStream(UInt64)
    } derive(Eq,
    Debug
    )

    An HTTP/3 unidirectional stream's type (RFC 9114 §6.2, RFC 9204 §4.2).

    Http3UniStream::equal

    Http3UniStream::not_equal

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

    Lifespan

    Drives an ASGI application's lifespan protocol (← uvicorn LifespanOn).

    A single long-lived invocation of the app runs under a Lifespan scope. The server and that invocation exchange messages through two async queues: the server pushes lifespan.startup / lifespan.shutdown onto inbox (which backs the app's Receive), and the app pushes its *.complete / *.failed replies onto outbox (which the app's Send writes to). state seeds the lifespan scope and is shared into request scopes by the caller.

    Lifespan::new

    Create a lifespan driver for app, with empty unbounded message queues and empty lifespan state.

    Lifespan::shutdown

    async fn Lifespan::shutdown(self : Lifespan, task :
    Task
    [Unit]) -> Unit

    Run ASGI lifespan shutdown: push lifespan.shutdown and await the app's reply, or return immediately if the app invocation has already finished. Raises LifespanError if the app reports lifespan.shutdown.failed.

    Lifespan::spawn

    Spawn the application under a Lifespan scope as a task in g, returning its handle. The task blocks on receive() until startup() / shutdown() push signals; a lifespan-aware app therefore stays parked for the server's whole life, while an app that ignores the lifespan scope simply returns at once (detected via the returned task in startup / shutdown).

    Lifespan::startup

    async fn Lifespan::startup(self : Lifespan, task :
    Task
    [Unit]) -> Unit

    Run ASGI lifespan startup: push lifespan.startup and await the app's reply. Returns once the app sends lifespan.startup.complete, or once the app returns without lifespan support (its task finishing wins the race, and startup is treated as a no-op, matching uvicorn's lifespan="auto"). Raises LifespanError if the app reports lifespan.startup.failed.

    Logger

    pub struct Logger {
    out :
    Logger

    access : Bool
    }

    How much the server says, and where it goes.

    Logger::access_line

    fn Logger::access_line(self : Logger, client : String, verb : String, target : String, http_version : String, status : Int) -> Unit

    Write the one-line-per-request access log (uvicorn's uvicorn.access), in the same shape: peer, request line, status.

    Logger::enabled

    fn Logger::enabled(self : Logger, level :
    Level
    ) -> Bool

    Whether a line at level would be written.

    Logger::log

    fn Logger::log(self : Logger, level :
    Level
    , message : String) -> Unit

    Write one server-lifecycle line (uvicorn's uvicorn.error logger, which carries ordinary startup messages as well as failures).

    Logger::new

    fn Logger::new(level? :
    Level
    , access? : Bool, write? : (String) -> Unit) -> Logger

    A logger printing to stdout at level, with the access log on — uvicorn's defaults. access off silences the per-request lines while keeping the server's own.

    Logger::silent

    fn Logger::silent() -> Logger

    A logger that says nothing, for an embedder that does its own reporting or a test that would rather not have output.

    QpackDecoderInst

    pub(all) enum QpackDecoderInst {
    SectionAck(stream_id~ : Int)
    StreamCancel(stream_id~ : Int)
    InsertCountIncrement(increment~ : Int)
    } derive(Eq,
    Debug
    )

    A decoder-stream instruction (RFC 9204 §4.4).

    QpackDecoderInst::equal

    QpackDecoderInst::not_equal

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

    QpackDynamicTable

    pub(all) struct QpackDynamicTable {
    entries : Array[(Bytes, Bytes)]
    used : Int
    capacity : Int
    insert_count : Int
    dropped : Int
    }

    A QPACK dynamic table: the live entries, the bytes they occupy, the capacity, the count of entries ever inserted (the next absolute index), and how many have been evicted.

    QpackDynamicTable::apply

    fn QpackDynamicTable::apply(self : QpackDynamicTable, inst : QpackEncoderInst) -> Bool raise QpackError

    Apply an encoder-stream instruction to the table (RFC 9204 §4.3): set the capacity, or insert an entry whose name comes from the static table, a dynamic entry (by relative index), or a literal. Returns whether the resulting insert fit (always true for a capacity change). Raises if a referenced name index is out of range.

    QpackDynamicTable::get

    fn QpackDynamicTable::get(self : QpackDynamicTable, abs_index : Int) -> (Bytes, Bytes)?

    The entry at absolute index abs_index, or None if it has been evicted or never existed (RFC 9204 §3.2.4).

    QpackDynamicTable::get_relative

    fn QpackDynamicTable::get_relative(self : QpackDynamicTable, rel : Int) -> (Bytes, Bytes)?

    The entry at encoder-stream relative index rel (0 = most recently inserted, RFC 9204 §3.2.5).

    QpackDynamicTable::insert

    fn QpackDynamicTable::insert(self : QpackDynamicTable, name : Bytes, value : Bytes) -> Bool

    Insert an entry, evicting as needed. Returns false (adding nothing) when the entry cannot fit even in an empty table (RFC 9204 §3.2.2).

    QpackDynamicTable::new

    fn QpackDynamicTable::new(capacity : Int) -> QpackDynamicTable

    A fresh, empty dynamic table with the given capacity in bytes.

    QpackDynamicTable::set_capacity

    fn QpackDynamicTable::set_capacity(self : QpackDynamicTable, capacity : Int) -> Unit

    Set the table capacity, evicting whatever no longer fits (RFC 9204 §3.2.3).

    QpackEncoderInst

    pub(all) enum QpackEncoderInst {
    SetCapacity(Int)
    InsertNameRef(is_static~ : Bool, index~ : Int, value~ : Bytes)
    InsertLiteralName(name~ : Bytes, value~ : Bytes)
    Duplicate(index~ : Int)
    } derive(Eq,
    Debug
    )

    An encoder-stream instruction (RFC 9204 §4.3).

    QpackEncoderInst::equal

    QpackEncoderInst::not_equal

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

    QuicUdpEndpoint

    pub struct QuicUdpEndpoint {
    server :
    UdpServer

    }

    A QUIC UDP endpoint: a bound datagram socket.

    QuicUdpEndpoint::bind

    async fn QuicUdpEndpoint::bind(addr : String) -> QuicUdpEndpoint

    Bind an endpoint to addr (e.g. "0.0.0.0:443", or "127.0.0.1:0" for an OS-assigned port). The bound address is available from local_addr.

    QuicUdpEndpoint::close

    fn QuicUdpEndpoint::close(self : QuicUdpEndpoint) -> Unit

    Close the endpoint's socket.

    QuicUdpEndpoint::local_addr

    The address the endpoint is bound to (the concrete port when bound to port 0).

    QuicUdpEndpoint::recv_datagram

    async fn QuicUdpEndpoint::recv_datagram(self : QuicUdpEndpoint, max? : Int) -> (Bytes,
    Addr
    )

    Receive the next datagram and its sender's address, copying up to max bytes.

    QuicUdpEndpoint::send_datagram

    async fn QuicUdpEndpoint::send_datagram(self : QuicUdpEndpoint, data : Bytes, to :
    Addr
    ) -> Unit

    Send data as a single datagram to to.

    ShutdownHandle

    pub struct ShutdownHandle {
    request :
    Queue
    [Unit]
    done :
    Queue
    [Unit]
    }

    A handle for driving a graceful shutdown of serve_graceful from outside the serving task (← uvicorn's Server.should_exit / handle_exit). Hand one to serve_graceful, then call shutdown() from any other task to stop the server: it stops accepting, waits for in-flight requests to drain, runs the ASGI lifespan shutdown, and closes the listener, in that order.

    Two async queues carry the handshake. request receives the shutdown trigger (a signal delivered through the runtime's global cancellation reaches the server the same way — see serve_graceful). done is posted once the server has finished the whole shutdown sequence, so shutdown() can block until the port is actually free.

    ShutdownHandle::new

    Create an idle shutdown handle with empty unbounded signal queues.

    ShutdownHandle::request_stop

    async fn ShutdownHandle::request_stop(self : ShutdownHandle) -> Unit

    Request a graceful shutdown without waiting for it to finish.

    ShutdownHandle::shutdown

    async fn ShutdownHandle::shutdown(self : ShutdownHandle) -> Unit

    Request a graceful shutdown and block until the server has drained in-flight requests, run lifespan shutdown, and closed the listener. Returns once the listen port is free again.

    TlsCert

    pub(all) struct TlsCert {
    certificate_file : String
    private_key_file : String
    pfx_file : String
    }

    TLS certificate material for HTTPS serving (← uvicorn's ssl_certfile / ssl_keyfile). The backend the moonbitlang/async TLS layer uses is platform-specific, so both forms are carried:

    • certificate_file + private_key_file — PEM files, used by the OpenSSL backend (Linux / macOS, the CI platforms);
    • pfx_file — a PKCS#12 bundle, used by the SChannel backend (Windows).

    serve_tls picks the right pair at compile time. Supply whichever your deployment targets; a self-signed certs/ pair for tests is generated with scripts/gen_test_cert.sh.

    WsConfig

    pub(all) struct WsConfig {
    max_size : Int
    }

    The WebSocket knobs uvicorn exposes on its own config (--ws-max-size).

    max_size is the ceiling on a received payload, in bytes: it bounds a single frame and, across continuation frames, a reassembled message, and a peer that announces more fails the connection with 1009. It is the knob with teeth on the receive side — an unbounded length is exactly what a hostile client sends.

    uvicorn's remaining WebSocket knobs are not here rather than here-and-ignored: --ws-ping-interval / --ws-ping-timeout need a heartbeat task alongside a reader that can be interrupted between messages and resumed, --ws-max-queue needs that same reader pumping a bounded queue, and neither is expressible while a native blocking read cannot be cancelled and resumed mid-stream; --ws-per-message-deflate needs a raw DEFLATE codec, which the async library's gzip package (gzip-framed streams only) does not provide. Accepting the extension without one would corrupt every frame.

    WsConfig::new

    fn WsConfig::new(max_size? : Int) -> WsConfig

    Build a WsConfig, defaulting max_size to uvicorn's own 16 MiB.

    WsFrame

    pub(all) struct WsFrame {
    fin : Bool
    opcode : WsOpcode
    payload : Bytes
    } derive(Eq,
    Debug
    )

    A decoded WebSocket frame (RFC 6455 §5.2): its FIN bit, opcode, and already-unmasked payload.

    WsFrame::equal

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

    WsFrame::not_equal

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

    WsFrame::to_repr

    WsMessage

    pub(all) enum WsMessage {
    WsText(String)
    WsBinary(Bytes)
    WsClose(Int, Bytes)
    } derive(Eq,
    Debug
    )

    A complete WebSocket application message read off a raw stream: a text or binary message with its reassembled payload, or a close with its code and reason bytes. Control frames never surface here — ws_read_message handles them — matching what uvicorn hands the ASGI app.

    A text message carries a String: §8.1 makes invalid UTF-8 a 1007 failure, so bytes that would not decode never reach this far and the app cannot be handed a lossily-mangled message.

    WsMessage::equal

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

    WsMessage::not_equal

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

    WsOpcode

    pub(all) enum WsOpcode {
    Continuation
    Text
    Binary
    Close
    Ping
    Pong
    } derive(Eq,
    Debug
    )

    A WebSocket frame opcode (RFC 6455 §5.2): the data opcodes (continuation / text / binary) and the control opcodes (close / ping / pong).

    WsOpcode::equal

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

    WsOpcode::is_control

    fn WsOpcode::is_control(self : WsOpcode) -> Bool

    Whether this is a control opcode (RFC 6455 §5.5), which the spec holds to tighter rules than a data frame: at most 125 payload bytes, and never fragmented.

    WsOpcode::not_equal

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

    WsOpcode::to_int

    fn WsOpcode::to_int(self : WsOpcode) -> Int

    The 4-bit wire value of an opcode.

    WsOpcode::to_repr

    ec_private

    A P-256 private key from the hex of its scalar.

    Hex is what a fixture and an example carry. A key from a file is PEM or DER, which is mooncrypt/key's to read when that package lands; until then a deployment reads its own file and hands the scalar in.

    h3_settings_max_field_section_size

    let h3_settings_max_field_section_size : UInt64

    SETTINGS_MAX_FIELD_SECTION_SIZE (RFC 9114 §7.2.4.1).

    h3_settings_qpack_blocked_streams

    let h3_settings_qpack_blocked_streams : UInt64

    SETTINGS_QPACK_BLOCKED_STREAMS (RFC 9204 §5).

    h3_settings_qpack_max_table_capacity

    let h3_settings_qpack_max_table_capacity : UInt64

    SETTINGS_QPACK_MAX_TABLE_CAPACITY (RFC 9204 §5).

    http3_check_frame

    fn http3_check_frame(frame : Http3Frame, ctx : Http3StreamCtx) -> Unit raise Http3FrameError

    Raise Http3FrameError (H3_FRAME_UNEXPECTED) when frame is not permitted on ctx.

    http3_decode_control_stream

    fn http3_decode_control_stream(input : BytesView) -> Http3Settings? raise Http3FrameError

    Decode an endpoint's control stream (RFC 9114 §6.2.1): its type prefix must be the control stream, and its first frame a SETTINGS frame, whose settings are returned. None if the stream is not a control stream or does not open with SETTINGS. Raises on a malformed frame.

    http3_decode_message

    fn http3_decode_message(bytes : Bytes) -> (Array[(String, String)], Bytes) raise Http3FrameError

    Decode an HTTP/3 request/response stream back into its header list and body: the first HEADERS frame gives the headers, the DATA frames concatenate into the body, and a second HEADERS frame (trailers) and unknown frames are ignored (RFC 9114 §4.1). Raises if there is no HEADERS frame or a frame is malformed.

    http3_decode_stream_type

    fn http3_decode_stream_type(input : BytesView) -> (Http3UniStream, Int)?

    Decode a unidirectional stream's opening bytes: the type varint (and a push id for a push stream). Returns the stream kind and bytes consumed, or None on a partial read.

    http3_encode_control_stream

    fn http3_encode_control_stream(settings : Array[(UInt64, UInt64)]) -> Bytes

    Encode a control stream (RFC 9114 §6.2.1): the control-stream type, then a SETTINGS frame with settings, which a control stream must send first.

    http3_encode_message

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

    Encode a message body as an HTTP/3 request/response stream: a HEADERS frame with the QPACK-encoded headers (static table only), then a DATA frame when body is non-empty.

    http3_encode_stream_type

    fn http3_encode_stream_type(stream : Http3UniStream) -> Bytes

    Encode a unidirectional stream's opening bytes: the type varint, plus the push id for a push stream (RFC 9114 §6.2.2).

    http3_frame_allowed

    fn http3_frame_allowed(frame : Http3Frame, ctx : Http3StreamCtx) -> Bool

    Whether frame is permitted on a stream of kind ctx (RFC 9114 §7.2).

    http3_frame_decode

    fn http3_frame_decode(input : BytesView) -> (Http3Frame, Int)? raise Http3FrameError

    Decode one HTTP/3 frame from the front of input. Returns Some((frame, consumed)) on a complete frame, None when more bytes are needed (a partial frame — the type, the length, or the payload has not fully arrived), and raises on a malformed frame (a truncated SETTINGS pair or a required varint field that is absent).

    http3_frame_decode_all

    fn http3_frame_decode_all(input : Bytes) -> (Array[Http3Frame], Int) raise Http3FrameError

    Decode every complete frame at the front of input, returning the frames and the number of octets consumed (the tail, a partial frame, is left for the next read). A stream reader feeds accumulated bytes and keeps the unconsumed remainder.

    http3_frame_encode

    fn http3_frame_encode(frame : Http3Frame) -> Bytes

    Encode one HTTP/3 frame: its type, its payload length, then the payload.

    http3_parse_request

    fn http3_parse_request(bytes : Bytes) -> Http3Request? raise Http3FrameError

    Decode an HTTP/3 request stream into a request, separating the pseudo-headers from the regular ones (RFC 9114 §4.3.1). None when the required :method and :path are absent. Raises on a malformed stream.

    http3_parse_request_strict

    fn http3_parse_request_strict(bytes : Bytes) -> Http3Request? raise Http3FrameError

    Parse a request stream, rejecting any frame the protocol does not allow on a request stream (RFC 9114 §7.2): a control-only frame such as SETTINGS is an H3_FRAME_UNEXPECTED connection error here, not silently ignored. Otherwise as http3_parse_request — None when the required :method / :path are absent. This is the request path with frame-placement validation applied.

    http3_request

    fn http3_request(meth : String, scheme : String, authority : String, path : String, headers : Array[(String, String)], body : Bytes) -> Bytes

    Encode an HTTP/3 request: the :method, :scheme, :authority, and :path pseudo- headers (RFC 9114 §4.3.1), then headers, then the body.

    http3_response

    fn http3_response(status : String, headers : Array[(String, String)], body : Bytes) -> Bytes

    Encode an HTTP/3 response: the :status pseudo-header (RFC 9114 §4.3.2), then headers, then the body.

    http3_serve

    fn http3_serve(request_bytes : Bytes, handler : (Http3Request) -> Http3Response) -> Bytes raise Http3FrameError

    Serve one HTTP/3 request stream: decode the request, apply handler, and encode its response stream. A request missing its pseudo-headers is answered with a 400. Raises on a malformed request stream.

    http3_settings_decode

    fn http3_settings_decode(payload : Bytes) -> Array[(UInt64, UInt64)] raise Http3FrameError

    Decode a SETTINGS payload into its (identifier, value) pairs. A trailing partial pair (an identifier with no value, or a truncated varint) is a frame error.

    http3_settings_encode

    fn http3_settings_encode(pairs : Array[(UInt64, UInt64)]) -> Bytes

    Encode a SETTINGS payload: each pair as its identifier varint then its value varint (RFC 9114 §7.2.4).

    http_date

    fn http_date(ms : Int64) -> String

    Format a Unix-epoch millisecond count as an HTTP-date in the IMF-fixdate form RFC 7231 §7.1.1.1 makes mandatory for a Date header: Sun, 06 Nov 1994 08:49:37 GMT, always GMT, always fixed-width.

    The calendar conversion is the standard civil-from-days one: shift the epoch to a March-based year so the leap day falls at the end of the era, then divide out 400-year eras. It is exact for any instant an Int64 millisecond count can name, and needs no locale and no clock but the one that produced ms.

    http_date_now

    fn http_date_now() -> String

    The current instant as an HTTP-date, reformatted only when the epoch second has moved on.

    port

    let port : Int

    The port every server in this family listens on unless told otherwise.

    Not 8000, which is uvicorn's, and not 8080: on a development machine those are taken by whatever ran last. One number of our own means every example, every README and every round of integration testing agree without anyone having to say which port they meant. HTTP, WebSocket, HTTP/2 and gRPC all share it — one listener tells them apart — and QUIC takes the same number on UDP, which is a different port space.

    proxy_rewrite

    fn proxy_rewrite(headers : Map[String, String], client : (String, Int)?, scheme : String, trusted~ : Array[String], websocket? : Bool) -> ((String, Int)?, String)

    Rewrite a request's client and scheme from a trusted proxy's forwarding headers (← uvicorn's ProxyHeadersMiddleware).

    Nothing is rewritten unless the peer itself is trusted: these headers are attacker-controlled, and believing them from an arbitrary client lets that client name any address it likes for a rate limiter, an allowlist or an audit log. Behind a proxy the peer is the proxy, which is why forwarded_allow_ips defaults to 127.0.0.1 and not to *.

    X-Forwarded-For yields the chain's leftmost entry with port 0, since a forwarded chain carries no port. X-Forwarded-Proto replaces the scheme, mapped to wss/ws for a WebSocket scope, whose scheme names differ from the https/http the header spells.

    qpack_decode_decoder_inst

    fn qpack_decode_decoder_inst(input : BytesView) -> (QpackDecoderInst, Int)?

    Decode one decoder-stream instruction from the front of input (RFC 9204 §4.4). Returns (instruction, bytes-consumed), or None on a partial read.

    qpack_decode_encoder_inst

    fn qpack_decode_encoder_inst(input : BytesView) -> (QpackEncoderInst, Int)? raise QpackError

    Decode one encoder-stream instruction from the front of input (RFC 9204 §4.3), dispatching on the pattern in the first byte's high bits. Returns (instruction,bytes-consumed), or None when the instruction has not fully arrived.

    qpack_decode_field_section

    fn qpack_decode_field_section(input : Bytes) -> Array[(String, String)] raise QpackError

    Decode a QPACK field section (encoded over the static table) back into its header list. The prefix's insert count and base are read and required to be zero (this decoder resolves static references only); a field line that references the dynamic table is reported as unsupported.

    qpack_decode_field_section_dyn

    fn qpack_decode_field_section_dyn(input : Bytes, table : QpackDynamicTable, max_entries : Int) -> Array[(String, String)] raise QpackError

    Decode a QPACK field section that may reference the dynamic table, given max_entries (from the negotiated maximum table capacity). Reads the section prefix, then every field line — static or dynamic, indexed or literal, pre- or post-base — into the header list. Raises if a reference is out of range or the section is malformed.

    qpack_decode_required_insert_count

    fn qpack_decode_required_insert_count(encoded : Int, max_entries : Int, total_inserts : Int) -> Int raise QpackError

    Reconstruct the absolute Required Insert Count from its encoded form, max_entries, and the number of inserts the decoder has processed (RFC 9204 §4.5.1.1).

    qpack_decode_section_prefix

    fn qpack_decode_section_prefix(input : BytesView, max_entries : Int, total_inserts : Int) -> (Int, Int, Int)? raise QpackError

    Decode a field-section prefix (RFC 9204 §4.5.1), given max_entries and the decoder's total inserts so far. Returns (req_insert_count, base, bytes-consumed), or None on a partial read.

    qpack_decode_string

    fn qpack_decode_string(input : BytesView, prefix_bits : Int) -> (Bytes, Int)? raise QpackError

    Decode a QPACK string literal whose length uses a prefix_bits-prefix integer and whose H bit sits at position prefix_bits. Returns (octets, bytes-consumed), or None when the string has not fully arrived.

    qpack_encode_decoder_inst

    fn qpack_encode_decoder_inst(inst : QpackDecoderInst) -> Bytes

    Encode a decoder-stream instruction (RFC 9204 §4.4).

    qpack_encode_dynamic

    fn qpack_encode_dynamic(headers : Array[(String, String)], table : QpackDynamicTable, max_entries : Int, huffman? : Bool) -> (Bytes, Bytes)

    Encode headers with dynamic indexing over table (the encoder's view, mutated by the inserts). Returns the encoder-stream instruction block and the field section. max_entries comes from the negotiated maximum table capacity.

    qpack_encode_encoder_inst

    fn qpack_encode_encoder_inst(inst : QpackEncoderInst, huffman? : Bool) -> Bytes

    Encode an encoder-stream instruction (RFC 9204 §4.3). huffman chooses whether the literal strings of the insert instructions are Huffman-coded.

    qpack_encode_field_section

    fn qpack_encode_field_section(headers : Array[(String, String)], huffman? : Bool) -> Bytes

    Encode a header list as a QPACK field section over the static table only: a zero-insert-count / zero-base prefix, then one field line per header — an indexed static entry for an exact match, a static name reference plus a literal value when the name is known, or a fully literal name and value otherwise. huffman chooses whether literal strings are Huffman-coded.

    qpack_encode_section_prefix

    fn qpack_encode_section_prefix(req_insert_count : Int, base : Int, max_entries : Int) -> Bytes

    Encode a field-section prefix (RFC 9204 §4.5.1): the Required Insert Count transmitted modulo 2*max_entries, then the Base as a sign bit (0 when base >= req_insert_count) plus the delta.

    qpack_encode_string

    fn qpack_encode_string(data : Bytes, prefix_bits : Int, pattern : Int, huffman : Bool) -> Bytes

    Encode a QPACK string literal: the H bit at position prefix_bits (set when the data is Huffman-coded), pattern in the bits above it, the octet length as a prefix_bits-prefix integer, then the (raw or Huffman) octets.

    qpack_entry_size

    fn qpack_entry_size(name : Bytes, value : Bytes) -> Int

    The size an entry occupies in the table: its name and value octets plus 32 (RFC 9204 §3.2.1).

    qpack_int_decode

    fn qpack_int_decode(input : BytesView, prefix_bits : Int) -> (Int, Int)?

    Decode an RFC 7541 §5.1 prefix_bits-prefix integer from the front of input (its first byte holds the value in its low prefix_bits, any flags in the high bits are ignored). Returns (value, bytes-consumed), or None when a continuation byte has not yet arrived (a partial read).

    qpack_int_encode

    fn qpack_int_encode(value : Int, prefix_bits : Int, flags : Int) -> Bytes

    Encode value as an RFC 7541 §5.1 prefix_bits-prefix integer, OR-ing flags into the high 8 - prefix_bits bits of the first byte (the representation's pattern).

    qpack_max_entries

    fn qpack_max_entries(max_capacity : Int) -> Int

    The number of entries that fit in a table of max_capacity bytes at the 32-byte minimum entry size — MaxEntries in RFC 9204 §4.5.1.1.

    qpack_static_find

    fn qpack_static_find(name : String, value : String) -> Int?

    The index of the first entry whose name and value both match, for an indexed field line; None if no entry matches exactly.

    qpack_static_find_name

    fn qpack_static_find_name(name : String) -> Int?

    The index of the first entry whose name matches (regardless of value), for a literal field line with a name reference; None if no entry has the name.

    qpack_static_get

    fn qpack_static_get(index : Int) -> (String, String)?

    The (name, value) entry at index, or None if index is out of range.

    qpack_static_len

    fn qpack_static_len() -> Int

    The number of entries in the QPACK static table (99).

    quic_now

    fn quic_now() -> Int64

    The event loop's clock, in the microseconds the recovery and idle timers are measured in. @async.now() counts milliseconds since the epoch.

    quic_serve

    async fn quic_serve(addr : String, server :
    Endpoint
    [
    Addr
    ], tick_ms? : Int) -> Unit

    Bind a UDP socket at addr (e.g. "0.0.0.0:443") and serve server on it. The socket outlives the loop deliberately: closing one a task is parked in recvfrom on wedges it rather than waking it, so shutdown is cancellation of the task group, not a close.

    quic_serve_endpoint

    async fn quic_serve_endpoint(ep : QuicUdpEndpoint, server :
    Endpoint
    [
    Addr
    ], tick_ms? : Int) -> Unit

    Serve QUIC on ep until the enclosing task group is cancelled: one task reads datagrams and demultiplexes them onto connections, the other runs the idle, probe and closing-period timers every tick_ms milliseconds, and both push out whatever that left ready to send. A datagram that names no connection or fails to authenticate is dropped rather than killing the loop — an endpoint cannot tell a stray packet from an attack.

    reload_watch

    async fn reload_watch(path : String, on_reload : async () -> Unit) -> Unit

    Watch path for source changes and fire on_reload on each batch of events (← uvicorn's --reload file watcher). Backed by @fs.Watcher, which debounces and reports child-file events, so a save to any file under path triggers one reload. on_reload is where a supervisor re-execs the server; wiring it to a ShutdownHandle turns a file save into a graceful restart. Loops until its task is cancelled, always closing the watcher.

    serve

    async fn serve(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, host? : String, port? : Int, backlog? : Int) -> Unit

    Serve a moonasgi ASGI application over native HTTP/1.1 + WebSocket (← uvicorn). Convenience wrapper over serve_config that builds a Config from host / port / backlog. Blocks in a keep-alive accept loop until the running task is cancelled.

    serve_config

    async fn serve_config(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, config : Config) -> Unit

    Serve a moonasgi ASGI application under an explicit Config.

    Runs the full lifespan protocol around the accept loop: the app is invoked once under a Lifespan scope and startup() is driven before the listener is bound, and shutdown() is driven on the way out — even when the serving task is cancelled, via protect_from_cancel. Each accepted request is bridged by dispatch, which diverts WebSocket upgrades to the echo path.

    serve_graceful

    async fn serve_graceful(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, config : Config, handle? : ShutdownHandle) -> Unit

    Serve a moonasgi application with a uvicorn-style process model: a graceful shutdown path over a single acceptor that spawns a concurrent handler per connection.

    The lifespan protocol runs as in serve_config — startup before the listener binds, shutdown on the way out. A single acceptor task then drives accepted connections through the same dispatch path serve uses (over a hand-built @http.ServerConnection), spawning one handler task per connection so requests are served concurrently — and, because it is the real ServerConnection, WebSocket upgrades bridge here too.

    Shutdown is triggered by handle.shutdown() / handle.request_stop(), by reaching Config::limit_max_requests, or by a signal the runtime turns into global cancellation (see the boundary note below). All three converge on one sequence, run under protect_from_cancel so a signal can't abort it midway: stop accepting (cancel the acceptor), drain in-flight requests (bounded by Config::graceful_timeout), run the ASGI lifespan shutdown, then close the listener.

    The acceptor honours the same ceilings the other serve paths get from run_forever: max_connections gates accepting, so a full server leaves the next client in the listen queue rather than accepting it and sitting on it, and allow_failure decides whether one bad connection is contained or brings the server down. limit_concurrency is the other kind of ceiling — a request arriving past it is answered 503 rather than queued.

    Multi-worker boundary

    uvicorn's --workers forks N OS processes that each bind the same port with SO_REUSEPORT for multi-core parallelism. moonbitlang/async exposes neither SO_REUSEPORT on TcpServer nor a fork primitive, and its event loop allows only one outstanding accept per listener handle (wait_read guards on a single waiter), so even N in-process acceptor tasks on one shared listener aren't expressible — a second concurrent accept on the same listener aborts. mooncat therefore serves from one acceptor that spawns a concurrent handler per connection, which is exactly the concurrency a single uvicorn worker provides on its single event loop. Multi-process fan-out is a transport limit, not a behavioural choice; it lands when the async layer exposes SO_REUSEPORT or a fork primitive.

    Signal boundary

    The only signal hook moonbitlang/async exposes is @signal.set_global_cancellation_signals, which cancels the whole task tree on SIGINT/SIGTERM. mooncat catches that cancellation and still runs lifespan shutdown and closes the listener under protect_from_cancel; but a signal also cancels the in-flight handler tasks, so drain-before-close is only fully honoured on the programmatic ShutdownHandle path. That matches uvicorn's own escalation: a first signal drains, a second forces exit.

    serve_h2c

    async fn serve_h2c(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, host? : String, port? : Int) -> Unit

    Serve a moonasgi ASGI application over HTTP/2 cleartext (h2c) — the prior-knowledge, no-TLS HTTP/2 profile (RFC 7540 §3.4) — reusing moonrpc's self-built HTTP/2 + HPACK engine as the transport. Convenience wrapper over serve_h2c_config that builds a Config from host / port. Blocks in the accept loop until the running task is cancelled.

    h2c rather than h2-over-TLS because the moonbitlang/async TLS layer exposes no ALPN, so the protocol can't be negotiated on a TLS connection yet; h2c is the direct, ALPN-free path a client reaches with prior knowledge (as curl --http2-prior-knowledge or a gRPC client does).

    serve_h2c_config

    async fn serve_h2c_config(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, config : Config) -> Unit

    Serve a moonasgi ASGI application over h2c under an explicit Config. Mirrors serve_config / serve_tls_config: the ASGI lifespan protocol runs around the accept loop (startup before the listener binds, shutdown on the way out — even under cancellation, guarded by protect_from_cancel), and each accepted connection is driven by drive_h2c over the self-built HTTP/2 transport.

    serve_tls

    async fn serve_tls(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, certificate_file~ : String, private_key_file~ : String, pfx_file~ : String, host? : String, port? : Int, backlog? : Int) -> Unit

    Serve a moonasgi ASGI application over native HTTP/1.1 over TLS (HTTPS, ← uvicorn's --ssl-certfile/--ssl-keyfile). Convenience wrapper over serve_tls_config that builds the Config from host/port/backlog and the certificate paths.

    Runs the full ASGI lifespan protocol around the accept loop (startup before the listener binds, shutdown on exit — even under cancellation), then, for every accepted connection, completes a TLS handshake and drives the app through the encrypted HTTP/1.1 codec.

    WebSocket-over-TLS (wss://) is not bridged here: the async websocket upgrade requires an @http.ServerConnection, which is welded to @socket.Tcp and cannot wrap a @tls.Tls stream. Plaintext serve retains full WebSocket support; this is a transport-capability boundary, not a behavioural choice (README §TLS).

    serve_tls_config

    async fn serve_tls_config(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, config : Config, cert : TlsCert) -> Unit

    Serve a moonasgi ASGI application over HTTPS under an explicit Config and TlsCert. Mirrors serve_config: lifespan startup is driven before the listener binds and shutdown on the way out (guarded by protect_from_cancel so it still runs when the serving task is cancelled), and each accepted connection is handled by handle_https_conn.

    tls13_certificate_verify_check

    fn tls13_certificate_verify_check(key :
    PublicKey
    , context : String, transcript_hash : Bytes, body : Bytes) -> Bool

    Check a CertificateVerify body against the peer's ES256 public key.

    False on a scheme this server does not verify, a malformed body, or a signature that does not check out — one answer for every way of being wrong, because which way it was wrong is not the peer's business.

    tls13_certificate_verify_sign

    fn tls13_certificate_verify_sign(key :
    PrivateKey
    , context : String, transcript_hash : Bytes) -> Bytes

    Sign a CertificateVerify over the transcript with an ES256 key (RFC 8446 §4.4.3): the message body, its scheme and signature framed by moontls, over the signed content moontls builds.

    tls13_client_hello_x25519

    fn tls13_client_hello_x25519(client_hello : Bytes) -> Bytes?

    The x25519 public key a ClientHello's key_share offers, or None when it carries none for that group.

    tls13_ecdhe_handshake_traffic_secret

    fn tls13_ecdhe_handshake_traffic_secret(private_key : Bytes, peer_public : Bytes, transcript_hash : Bytes, is_client : Bool) -> Bytes

    A handshake traffic secret straight from an ECDHE exchange and a transcript.

    is_client picks which side's secret: the two are derived from the same handshake secret under different labels (RFC 8446 §7.1), so one call serves both ends of a test and both ends of a connection.

    tls13_ecdhe_secrets_from_client_hello

    fn tls13_ecdhe_secrets_from_client_hello(client_hello : Bytes, server_private : Bytes, transcript_hash : Bytes) -> (Bytes, Bytes)?

    Both handshake traffic secrets from a ClientHello's key share: the server's and the client's, in that order.

    None when the ClientHello offers no x25519 share, which is the one case a server has to answer with a HelloRetryRequest rather than a secret.

    tls13_ecdhe_shared

    fn tls13_ecdhe_shared(private_key : Bytes, peer_public : Bytes) -> Bytes

    The x25519 shared secret with a peer's public key.

    A low-order peer share makes the secret all zeroes, which mooncrypt refuses; here that becomes an empty answer, because the handshake's response to it is an alert rather than a crash.

    tls13_handshake_secret_from_ecdhe

    fn tls13_handshake_secret_from_ecdhe(private_key : Bytes, peer_public : Bytes) -> Bytes

    The Handshake Secret from our scalar and the peer's key share: run the ECDHE, then RFC 8446 §7.1's ladder.

    tls13_quic_encrypted_extensions

    fn tls13_quic_encrypted_extensions(alpn : String, params : Array[
    Param
    ]) -> Bytes

    A QUIC server's EncryptedExtensions body: the negotiated ALPN protocol and the server's transport parameters.

    RFC 9001 §8.2 makes quic_transport_parameters mandatory for a QUIC server, and the parameters themselves are QUIC's — moontls carries the extension without reading it, so encoding them is this side's job.

    tls13_server_hs_secret_from_client_hello

    fn tls13_server_hs_secret_from_client_hello(client_hello : Bytes, server_private : Bytes, transcript_hash : Bytes) -> Bytes?

    A server's handshake traffic secret from the ClientHello it received: pull the client's x25519 key_share and run the ECDHE with server_private over the ClientHello..ServerHello transcript.

    None if the message is not a ClientHello or offers no x25519 share — the case a server answers with a HelloRetryRequest rather than a secret.

    tls13_x25519_public

    fn tls13_x25519_public(private_key : Bytes) -> Bytes

    The ephemeral x25519 public key for a private scalar (RFC 7748 §5).

    tls_hello_quic_transport_params

    fn tls_hello_quic_transport_params(extensions : Array[
    Ext
    ]) -> Array[
    Param
    ]

    The QUIC transport parameters an extension list carries, in order.

    Empty when the extension is absent or its block is truncated: a QUIC endpoint treats a missing block as no parameters and falls back to the defaults RFC 9000 §18.2 gives.

    tls_selected_alpn

    fn tls_selected_alpn(extensions : Array[
    Ext
    ]) -> String?

    The single ALPN protocol a server's extension list selected, or None.

    tls_server_hello_key_share

    fn tls_server_hello_key_share(sh :
    Server
    ) -> (Int, Bytes)?

    The group and key a ServerHello's key_share selected.

    websocket_accept_key

    fn websocket_accept_key(sec_websocket_key : String) -> String

    The Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key: base64 of the SHA-1 of the key concatenated with the GUID (RFC 6455 §4.2.2).

    websocket_guid

    let websocket_guid : String

    The WebSocket handshake GUID appended to the client key before hashing (RFC 6455 §4.2.2).

    websocket_handshake_response

    fn websocket_handshake_response(sec_websocket_key : String, subprotocol : String?, extra_headers : Array[(String, String)]) -> Bytes

    The 101 Switching Protocols response for a WebSocket upgrade (RFC 6455 §4.2.2): the Upgrade and Connection headers, the computed Sec-WebSocket-Accept, the selected subprotocol when one was negotiated, and any extra_headers.

    websocket_select_subprotocol

    fn websocket_select_subprotocol(offered : Array[String], supported : Array[String]) -> String?

    Select the first client-offered subprotocol the server supports, or None if none match (RFC 6455 §4.2.2): the client's order is its preference.

    ws_check_frame

    fn ws_check_frame(b0 : Int, b1 : Int, len : Int64, from_client~ : Bool, max_size~ : Int) -> WsOpcode raise WsError

    Check a frame's wire header against RFC 6455's receive rules and return its opcode. b0 and b1 are the first two header bytes; len is the payload length already widened out of whichever of the three length forms carried it.

    Raises WsError with the status code the connection must then be failed with (§7.1.7):

    • a set RSV1/2/3 bit, no extension having been negotiated → 1002 (§5.2);
    • a reserved opcode → 1002 (§5.2);
    • an unmasked frame, when from_client says the peer is a client → 1002 (§5.1);
    • a control frame carrying more than 125 bytes, or fragmented → 1002 (§5.5);
    • a length past max_size → 1009, the 64-bit form's sign bit included (§5.2 reserves it).

    Callers must run this before they read the payload: it is the length check that keeps a 64-bit length from being truncated into a short allocation, and keeps an honest-but-enormous one from being allocated at all.

    ws_close_payload

    fn ws_close_payload(code : Int, reason : String) -> Bytes

    Build a close frame's payload (RFC 6455 §5.5.1): the 16-bit status code big-endian followed by the UTF-8 reason.

    ws_decode_frame

    fn ws_decode_frame(b : BytesView, from_client? : Bool, max_size? : Int) -> (WsFrame, Int)? raise WsError

    Decode one WebSocket frame at the start of b (RFC 6455 §5.2), returning the frame (its payload unmasked) and the byte count it consumed, or None when b does not yet hold a whole frame.

    from_client selects the receive rules of a server reading a client (the default: masking is mandatory); pass false to decode a frame travelling the other way. A frame that breaks the rules raises WsError rather than returning None, so a violation is never mistaken for a short buffer — see ws_check_frame.

    ws_encode_frame

    fn ws_encode_frame(fin : Bool, opcode : WsOpcode, payload : Bytes, mask? : Bytes) -> Bytes

    Encode a WebSocket frame (RFC 6455 §5.2). A 4-byte mask sets the MASK bit and masks the payload (clients MUST mask, servers MUST NOT — pass b"" for an unmasked server frame). The payload length takes the shortest of the 7-bit (≤125), 16-bit (126), or 64-bit (127) forms.

    ws_max_size

    let ws_max_size : Int

    The default ceiling on a received payload, in bytes (← uvicorn's --ws-max-size, 16 MiB). It bounds both a single frame and a reassembled fragmented message; past it the connection is failed with 1009 rather than allocating whatever the peer asked for.

    ws_opcode_of_int

    fn ws_opcode_of_int(n : Int) -> WsOpcode?

    The opcode for a 4-bit wire value, or None for a reserved opcode.

    ws_read_frame

    async fn ws_read_frame(reader : &
    Reader
    , from_client? : Bool, max_size? : Int) -> WsFrame

    Read one WebSocket frame off a raw byte stream (RFC 6455 §5.2) — the runtime counterpart of ws_decode_frame. Reads the 2-byte prefix, then the extended length (16- or 64-bit), and only once ws_check_frame has passed the header does it read the masking key and the payload.

    from_client defaults to the server's position — a client's frames must be masked (§5.1) — so reading a frame the server sent needs from_client=false. Raises WsError carrying the close code for any violation, and propagates the reader's end-of-stream.

    ws_read_message

    async fn ws_read_message(reader : &
    Reader
    , writer : &
    Writer
    , max_size? : Int) -> WsMessage

    Read one complete WebSocket message off reader, reassembling a fragmented message from its continuation frames (RFC 6455 §5.4) and handling interleaved control frames (§5.5): a ping is answered on writer with a pong, a pong is dropped, and a close is echoed back per §5.5.1 before the message ends with its code and reason.

    This is the server's receive path, so every frame must arrive masked. Raises WsError carrying the close code the connection is then failed with: 1002 for a framing violation (a stray continuation frame, or a new data frame arriving before the current message finished) and whatever ws_check_frame decided for a bad header, 1007 for a text message that is not valid UTF-8 (§8.1), 1009 for a reassembled message past max_size.