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; the protocols themselves are moonhttp, moontls and moonquic.

    asgi
    server
    uvicorn
    http
    websocket
    moonbit
    native
    Download zip
    Version
    0.14.6
    License
    Apache-2.0
    Last updated
    57 minutes ago
    Downloads
    43

    #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 moonhttp/http1's RFC 9112 codec — which frames single-shot replies with Content-Length and streamed replies with Transfer-Encoding: chunked, and refuses the request-smuggling framings rather than resolving them — drives the moonasgi app over the encrypted stream. A pipelining client's second request, or a body that arrived with its head, is held for what comes next rather than dropped, 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 framing is moonhttp/http2 (RFC 9113) and the header compression moonhttp/hpack (RFC 7541) — the same two that carry real gRPC in moonrpc, which is why neither lives in either server. 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 9113 §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.

    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.

    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.

    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.

    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.

    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.

    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 and the format are moondate's; what is here is the millisecond clock mooncat reads, brought down to the whole second the header is written to.

    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.

    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.

    read_frame

    async fn read_frame(reader : &
    Reader
    , client? : Bool, limit? : Int) ->
    Frame

    One frame off a stream (RFC 6455 §5.2).

    The header is read in the order the format is laid out — two bytes, then the extended length, then the mask — and @ws.check runs on the header before the payload is read, so an enormous announced length is refused rather than allocated.

    client is the server's position by default: a client's frames must be masked (§5.1), so reading a server's frames takes client=false. Raises @ws.Refused carrying the status the connection is then failed with, and passes the stream's end through.

    read_message

    One complete message off a stream, with the control frames between its fragments handled: a ping is answered on writer, a pong is dropped, and a close is echoed before the message comes back as that close.

    This is the server's receive path, so every frame must arrive masked. The reply frames go out unmasked because a server must not mask (§5.1).

    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.