mooncat

mooncat — a native ASGI 3.0 server for MoonBit (← uvicorn), built on moonbitlang/async and the moonasgi SEAM.

asgi
server
uvicorn
http
websocket
moonbit
native
moon add Lfan-ke/mooncat@0.6.2
Download zip
Author
Version
0.6.2
License
Apache-2.0
Last updated
18 days ago
Downloads
31
README

#mooncat

A native ASGI 3.0 server for MoonBit — ← uvicorn.

Check and Test License mooncakes

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

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

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=8000), 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=8000)

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). Proven by a real @websocket client doing a full text + binary round-trip and a clean close in CI;
  • a Config exposing the HTTP/1.1 transport knobs the async server honours — dual_stack, reuse_addr, per-server response headers, max_connections, and allow_failure — on top of host/port/backlog. Keep-alive and chunked request/response framing are handled automatically by the async transport.

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 (awaits a transport hook), the self-built HTTP/1.1 parser knobs (Expect: 100-continue, buffer limits), further TLS detail (ciphers/mTLS), h2 over TLS once the async layer exposes ALPN, and multi-process prefork once it exposes SO_REUSEPORT or fork.

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

#
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?
}

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.

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

#
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

fn Lifespan::new(app : async (
Scope
, async () ->
Event
, async (
Event
) -> Unit) -> Unit) -> Lifespan

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.

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

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

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