mooncat
A native ASGI 3.0 server for MoonBit. It runs a moonasgi app over a real network socket — accept a connection, turn each request into a Scope / Receive / Send, and drive your app — exactly the role uvicorn plays for Python.
moon add Lfan-ke/mooncat✶ The contract at a glance
pub async fn serve(app : AsgiApp, host? : String, port? : Int, backlog? : Int) -> Unit pub async fn serve_config(app : AsgiApp, config : Config) -> Unit pub async fn serve_tls(app : AsgiApp, certificate_file~, private_key_file~, pfx_file~, ...) -> Unit pub async fn serve_graceful(app : AsgiApp, config : Config, handle? : ShutdownHandle) -> Unit pub async fn serve_h2c(app : AsgiApp, host? : String, port? : Int) -> Unit // One socket -> Scope / Receive / Send -> your moonasgi app: // HTTP/1.1 request -> Http scope -> HttpResponseStart + HttpResponseBody // HTTP/2 (h2c) -> Http scope -> HEADERS + DATA, flow-controlled // Upgrade: websocket -> WebSocket scope -> connect/accept/receive/send/close // process boundary -> Lifespan scope -> startup ... shutdown // handle.shutdown() -> stop accepting -> drain -> lifespan shutdown -> close
§Serving
The accept loop that turns each native HTTP/1.1 connection into a Scope / Receive / Send and drives your moonasgi app — with the lifespan protocol run around it and WebSocket upgrades diverted to the frame bridge.
async fn serve( app : @moonasgi.AsgiApp, host? : String = "127.0.0.1", port? : Int = 8000, backlog? : Int = 2048) -> 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.
async fn serve_config(app : @moonasgi.AsgiApp, 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.
§WebSocket bridge
The frame↔Event bridge that drives a websocket Scope through the SEAM: connect / accept / receive (text & binary) / send / disconnect / close, with subprotocol parsing and protocol-layer ping/pong.
§Configuration
The Config record (← uvicorn Config): bind address, backlog, and the HTTP/1.1 transport knobs the async server honours — dual-stack, reuse-addr, server headers, connection ceiling, failure isolation.
struct Config
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, so the value is recorded on the config for parity and future use but is not yet applied to the socket — this is a transport-capability gap, not a behavioural choice. The remaining fields map one-to-one onto knobs the async server *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 (uvicorn's limit_concurrency), and allow_failure for whether a handler error tears the whole server down. Keep-alive and chunked request/response framing are handled automatically by the async server (each connection loops over multiple requests, and Content-Length / Transfer-Encoding are chosen by the sender), so they need no explicit knob — mirroring uvicorn's default keep-alive behaviour.
fn Config::new( host? : String = "127.0.0.1", port? : Int = 8000, backlog? : Int = 2048, dual_stack? : Bool = false, reuse_addr? : Bool = true, server_headers? : Map[String, String] = Map([]), max_connections? : Int? = None, allow_failure? : Bool = true, graceful_timeout? : Int? = None) -> Config
Build a Config, defaulting to uvicorn's own defaults: host 127.0.0.1, port 8000, backlog 2048. 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.
fn Config::bind(self : Config) -> String
The host:port string used to resolve the listen address.
§Lifespan
The ASGI lifespan driver (← uvicorn LifespanOn): startup is driven before the listener binds and shutdown on the way out, even under cancellation.
suberror LifespanErrorRaised when an application reports lifespan.startup.failed or lifespan.shutdown.failed, carrying the failure message the app supplied.
struct 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.
fn Lifespan::new(app : @moonasgi.AsgiApp) -> Lifespan
Create a lifespan driver for app, with empty unbounded message queues and empty lifespan state.
fn Lifespan::spawn( self : Lifespan, g : @async.TaskGroup[Unit]) -> @async.Task[Unit]
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).
async fn Lifespan::startup( self : Lifespan, task : @async.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.
async fn Lifespan::shutdown( self : Lifespan, task : @async.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.
§HTTPS / TLS
Serving HTTP/1.1 over TLS (← uvicorn --ssl-certfile/--ssl-keyfile): a self-built HTTP/1.1 codec drives the moonasgi app over a @tls.Tls stream, with certificate material given as PEM (OpenSSL platforms) or PKCS#12 (Windows).
struct TlsCert
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.
async fn serve_tls( app : @moonasgi.AsgiApp, certificate_file~ : String, private_key_file~ : String, pfx_file~ : String, host? : String = "127.0.0.1", port? : Int = 8443, backlog? : Int = 2048) -> 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).
async fn serve_tls_config( app : @moonasgi.AsgiApp, 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.
§HTTP/2 (h2c)
Serving the same moonasgi app over HTTP/2 cleartext (h2c), reusing moonrpc's self-built HTTP/2 frame layer and HPACK engine: it reads the client preface + SETTINGS, decodes request HEADERS into a Scope, streams DATA as the receive body, and encodes the response HEADERS + DATA back under connection- and stream-level flow control.
async fn serve_h2c( app : @moonasgi.AsgiApp, host? : String = "127.0.0.1", port? : Int = 8000) -> 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).
async fn serve_h2c_config(app : @moonasgi.AsgiApp, 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.
§Process model
The uvicorn process model: a graceful shutdown that stops accepting, drains in-flight requests, runs lifespan shutdown, then closes the listener, plus a --reload file watcher. The graceful acceptor drives the same request path as serve over a real ServerConnection, so WebSocket upgrades work here too. Multi-worker prefork is bounded by the async transport — one acceptor, concurrent per-connection handlers.
struct ShutdownHandle
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.
fn ShutdownHandle::new() -> ShutdownHandle
Create an idle shutdown handle with empty unbounded signal queues.
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.
async fn ShutdownHandle::request_stop(self : ShutdownHandle) -> Unit
Request a graceful shutdown without waiting for it to finish.
async fn serve_graceful( app : @moonasgi.AsgiApp, config : Config, handle? : ShutdownHandle = ShutdownHandle::new()) -> 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 either by handle.shutdown() / handle.request_stop() or by a signal the runtime turns into global cancellation (see the boundary note below). Both 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. ## 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.
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.