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, TLS and HTTP/3, a uvicorn-shaped command line, and a process model with graceful shutdown; the protocols themselves are moonhttp, moontls and moonquic.

    asgi
    server
    uvicorn
    http
    websocket
    moonbit
    native
    Download zip
    Version
    0.16.0
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    208

    #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

    serve is native-only — it opens a socket — and it is async, so the entry point is async fn main and the target is --target native:

    async fn main {
    let app : @moonasgi.AsgiApp = (_scope, _receive, send) => {
    send(
    @moonasgi.Event::HttpResponseStart(
    status=200,
    headers=[("content-type", "text/plain; charset=utf-8")],
    trailers=false,
    ),
    )
    send(
    @moonasgi.Event::HttpResponseBody(body=b"Hello from mooncat!", more_body=false),
    )
    }
    @mooncat.serve(app, host="127.0.0.1", port=12000) catch {
    err => println("mooncat stopped: \{err}")
    }
    }

    moon run src/main --target native curl http://127.0.0.1:12000

    moon.mod needs moonbitstack/mooncat and moonbitstack/moonasgi; the package importing them needs both in its moon.pkg. moon run examples/00-hello--target native is the same program in this repository.

    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.

    #Command line

    uvicorn main:app --port 9000 names the app by string because Python imports by string. MoonBit links it in, so that half does not carry over — but the half a user types does. @cli.run(app) is the whole body of a server program:

    async fn main {
    @cli.run(app) catch {
    err => println("mooncat: \{err}")
    }
    }

    moon run src/main --target native -- --port 9000 --log-level debug moon run src/main --target native -- --help

    --help lists every flag. The names and meanings are uvicorn's, including --host, --backlog, --root-path, --limit-concurrency, --limit-max-requests, --timeout-keep-alive, --timeout-graceful-shutdown, --h11-max-incomplete-event-size, --forwarded-allow-ips, --ws-max-size, --ssl-certfile with --ssl-keyfile, and the --no- form of every switch. mooncat adds --pfx-file (SChannel takes PKCS#12, not PEM), --h2c, --max-connections, --dual-stack and --reuse-addr.

    A uvicorn flag mooncat cannot honour says why, rather than calling it a typo:

    $ moon run src/main --target native -- --workers 4 mooncat: --workers: mooncat serves from one process; there is no fork here

    That covers --reload, --workers, --uds, --fd, --factory, --app-dir, --interface, --loop, --http, --ws, --log-config, --env-file, the three WebSocket timing knobs, and the --ssl-* options beyond the two above. Each answers with its own reason. Silently ignoring them is worse: somebody who writes --workers 4 would find out at load time.

    cli is a subpackage because reading argv is a program's business, not a library's — mooncat embedded in something larger never touches the command line. @mooncat.serve(app, port=12000) is unchanged, and both ways of starting a server stand.

    @cli.parse(argv) on its own returns the Plan — a Config, whether to wrap it in TLS, whether to speak h2c — without opening anything, which is how it is tested and how a program can look at the plan before serving.

    #HTTPS / TLS

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

    // All three are required arguments; `serve_tls` reads the pair its platform
    // accepts and ignores the other, so one call compiles everywhere.
    @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 and 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://) works: an upgrade on a TLS connection goes through mooncat's own HTTP/1.1 and WebSocket code over the encrypted stream, with the negotiated subprotocol echoed in the 101. A test does the upgrade with a real TLS client and checks the echoed frame.

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

    Every entry point (serve, serve_config, serve_tls_config, serve_graceful) runs this one loop, so they share the same limits, keep-alive, pipelining and WebSocket bridge; serve_graceful only adds the handle. A real @websocket client doing a text + binary round-trip against a serve_graceful server verifies the bridge in CI.

    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: uvicorn's --workers binds the port once in the parent and hands the socket to N worker processes started with multiprocessing's spawn context. moonbitlang/async can start a process but cannot give it a listening socket, 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 either. mooncat serves from one acceptor that spawns a concurrent handler per connection, the same concurrency a single uvicorn worker gives on its event loop. Multiple workers land when the async layer can pass a listening socket to a child process.

    #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 h2c greet test reuses the hand-written ASGI handler from the HTTP/1.1 test: /greet/moon returns JSON with 200, and an unmatched path returns 404.

    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. The underlying dependencies provide QUIC and HTTP/3 protocol components, but mooncat does not currently expose an ASGI-over-HTTP/3 server.

    #Packages

    PackageWhatWhat it costs to link
    mooncatThe accept loop, HTTP/1.1, h2c, WebSocket, TLS, the process model, Configmoonasgi, moonhttp's http1 / http2 / hpack / ws / upgrade, moonbitlang/async
    mooncat/cliuvicorn's flags, and run(app)the above, plus core's argparse
    mooncat/quicQUIC and HTTP/3: the UDP socket, the datagram loop, the handshake QUIC does inlinemoonquic, moontls, mooncred's certificate reader, moonhttp/http3

    QUIC is separate because of that third column. It performs its own TLS 1.3 handshake rather than riding a record layer, so it is the only half of mooncat that needs those four; a server answering HTTP/1.1 links none of them.

    #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 (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. WebSocketAccept(subprotocol=..) echoes the choice in the 101's Sec-WebSocket-Protocol. Rejecting before accept answers 403; pings are answered and, as in uvicorn, not surfaced to the app. 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), limit_max_requests (the server stops itself), 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 serves a small hand-written ASGI handler end to end: the HTTP/1.1 integration test answers /greet/<name> with JSON over a live socket, and verifies both 200 and a 404 route miss. A matching h2c test exercises the same handler over HTTP/2.

    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: further TLS detail (ciphers/mTLS), h2 over TLS once the async layer exposes ALPN, multiple workers once it can pass a listening socket to a child process, 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 — there is no JS server backend — so mooncat builds and runs on the native target.

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

    The protocol implementations come from mooncrypt, mooncred, moontls, moonhttp, and moonquic. mooncat owns the server-side integration: socket listeners, ASGI request and event bridging, graceful process handling, and TLS and QUIC endpoint binding. It does not currently provide an ASGI-over-HTTP/3 server.

    #Listening

    The TCP serving entry points default to port 12000. HTTP, HTTPS, WebSocket, and h2c are separate server modes; each binds its own listener, so they cannot all share that port at the same time. Config::new(port=...) changes the TCP port. quic_serve binds the UDP address passed by the caller; UDP and TCP use separate port spaces, so they may use the same number.

    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.

    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.

    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 default port for the TCP serving entry points.

    HTTP/1.1, including WebSocket upgrades, uses one listener. TLS and h2c are separate server modes with their own listeners, so they cannot all bind this port at once. quic_serve takes an explicit UDP address; it may use the same number because UDP and TCP have separate port spaces.

    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.

    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 startup before the listener binds and shutdown on the way out. A single acceptor task spawns one handler task per connection, so requests are served concurrently, each through mooncat's HTTP/1.1 codec and, on an upgrade, its WebSocket bridge. serve, serve_config and serve_tls_config run this same loop; this entry point adds the handle.

    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 connection ceilings: 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 binds the port once in the parent (SO_REUSEADDR, an inheritable socket) and hands that socket to N worker processes it starts with multiprocessing's spawn context. moonbitlang/async can start a process but cannot give it a listening socket, 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 the concurrency a single uvicorn worker provides on its event loop. Multiple workers land when the async layer can pass a listening socket to a child process.

    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.

    A WebSocket upgrade on one of these connections is served too (wss://): it goes through the same self-built HTTP/1.1 path, so the 101, the subprotocol echo and the frames all travel over the encrypted stream.

    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 completes a TLS handshake and then runs the same keep-alive loop as a plain one.