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.18.2
    License
    Apache-2.0
    Last updated
    2 hours ago
    Downloads
    227

    #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 --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 1.3 (← uvicorn's --ssl-certfile / --ssl-keyfile):

    @mooncat.serve_tls(
    app,
    certificate_file="certs/cert.pem", // the leaf first, then any intermediates
    private_key_file="certs/key.pem", // PRIVATE KEY, EC PRIVATE KEY or RSA PRIVATE KEY
    port=12000,
    )

    The TLS is moontls on moonnet's connections, so the same PEM files serve on Linux, macOS and Windows. The files are read once at startup, and a key that is not the certificate's is refused there. The listener offers h2 and http/1.1 by ALPN: a client that picks h2 is served HTTP/2, any other HTTP/1.1, WebSocket upgrades included (wss://). A handshake has timeout_handshake to finish, 60 seconds by default as in hypercorn; a connection ends with close_notify.

    CI drives it with the system's TLS as the client (OpenSSL, or SChannel on Windows): GET over https://, and a WebSocket upgrade with the subprotocol echoed in the 101. Regenerate the throwaway localhost test cert with scripts/gen-cert.sh.

    #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 request body reaches the app as it arrives, one http.request event per piece with more_body until the last, so an upload is never held whole in memory; a body the app leaves unread closes the connection rather than being read as the next request. A head that cannot be parsed is answered 400 (431 when it passes max_head_size) with uvicorn's Invalid HTTP request received. before the connection closes. 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

    serve and serve_tls speak HTTP/2 beside HTTP/1.1 on the same port. Under TLS the choice is ALPN's; in the clear a connection that opens with the HTTP/2 preface is served HTTP/2 (prior knowledge, as curl --http2-prior-knowledge or a gRPC client connects), and any other HTTP/1.1. The sniff reads only as far as the first byte that differs, so an HTTP/1.1 request is not held up by it. serve_h2c is a listener that speaks HTTP/2 and nothing else:

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

    The frames are moonhttp/http2 and the header compression moonhttp/hpack. CI drives it with a real HTTP/2 client over TLS and in the clear: a GET reads 200 and the body, a POST body is echoed, two streams share one connection, and with a small advertised window every DATA frame stays inside the granted window. mooncat does not yet expose an ASGI-over-HTTP/3 server.

    #Packages

    PackageWhatWhat it costs to link
    mooncatRe-exports the four below, so @mooncat.serve works as beforeall four
    mooncat/configConfig, access and error logging, the HTTP datemoonlog, moondate
    mooncat/lifespanthe ASGI lifespan protocolmoonasgi
    mooncat/proxyforwarded headers from a trusted proxy—
    mooncat/serveThe accept loop, workers and reload, HTTP/1.1, HTTP/2, WebSocket, TLSmoonasgi, moonhttp's http1 / http2 / hpack / ws / upgrade, moonnet, moontls
    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 HTTP/2 test exercises the same handler.

    HTTPS/TLS serving, the graceful-shutdown + --reload process model, and HTTP/2 serving under TLS and in the clear landed too — see the sections above. Roadmap, transliterated from uvicorn feature-by-feature: further TLS detail (ciphers, client certificates), TLS and the clear on one port, ASGI over HTTP/3, 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/1.1, WebSocket and HTTP/2 share one listener; HTTPS is still a listener of its own, until the first byte of a connection decides between TLS and the clear. 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.

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

    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.

    LifespanError

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

    Logger

    How much the server says, and where it goes.

    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.

    TlsCert

    The certificate chain and private key an HTTPS server presents (← uvicorn's ssl_certfile and ssl_keyfile), as PEM files. The chain file holds the leaf first, then any intermediates; the key file holds a PRIVATE KEY, EC PRIVATE KEY or RSA PRIVATE KEY. The same files serve on every platform: the TLS is moontls, not the system's.

    WsConfig

    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.

    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, WebSocket upgrades and HTTP/2 share one listener, told apart by ALPN under TLS and by the HTTP/2 preface in the clear. 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

    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

    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) only — the prior-knowledge, no-TLS HTTP/2 profile (RFC 9113 §3.3). Convenience wrapper over serve_h2c_config that builds a Config from host / port. Blocks in the accept loop until the running task is cancelled.

    serve and serve_tls speak HTTP/2 as well, beside HTTP/1.1 on the same port; this entry point is for a listener that is to speak nothing else.

    serve_h2c_config

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

    serve_tls

    async fn serve_tls(app : async (
    Scope
    , async () ->
    Event
    , async (
    Event
    ) -> Unit) -> Unit, certificate_file~ : String, private_key_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 serves HTTP/2 when ALPN settles on h2, HTTP/1.1 otherwise.

    A WebSocket upgrade on an HTTP/1.1 connection is served too (wss://): the 101, the subprotocol echo and the frames all travel over the encrypted stream.

    serve_tls_config

    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 within timeout_handshake and then runs the same loop as a plain one.