An ASGI 3.0 server for MoonBit.

    mooncat accepts a network connection, turns each request into a scope, a receive and a send, and drives your application with them. It speaks HTTP/1.1, HTTP/2, HTTP/3 and WebSocket, with TLS, on one port.

    An application is a function. It never sees a socket; the server never sees a route:

    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\n", more_body=false),
    )
    }
    @mooncat.serve(app, host="127.0.0.1", port=12000)
    }

    $ moon run src/main --target native $ curl -i http://127.0.0.1:12000/ HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 server: mooncat date: Mon, 02 Nov 2026 10:11:23 GMT content-length: 19 hello from mooncat

    #Install

    moon add moonbitstack/mooncat moon add moonbitstack/moonasgi

    Both, in moon.mod, and the importing package needs them in its moon.pkg.

    #One port, every version

    On the wireDecided byWhat the application sees
    HTTP/1.1the request line, in the clearan http scope; the body one piece at a time
    HTTPSa TLS 1.3 handshake, h2 and http/1.1 offered by ALPNthe same scope with scheme https, and the certificate in extensions
    h2cthe connection preface PRI * HTTP/2.0the same scope, once per stream
    HTTP/2ALPN answering h2one scope per stream, each stream in a task of its own
    HTTP/3the same port number on UDPone scope per stream, advertised with alt-svc
    ws, wssUpgrade: websocket on an ordinary requesta websocket scope: connect, accept, receive, send, close

    The listener decides as the connection opens: under TLS in the handshake, in the clear from the first bytes on the wire.

    #The entry points

    FunctionWhat it starts
    serve(app, host=, port=, backlog=)HTTP/1.1 and HTTP/2, one listener
    serve_config(app, config)the same, under an explicit Config
    serve_tls(app, certificate_file~, private_key_file~, ..)HTTPS, and the clear on the same port unless cleartext=false
    serve_tls_config(app, config, cert) · serve_tls_server(app, config, server)HTTPS with a certificate or a @tls.Server you built
    serve_h2c(app, host=, port=) · serve_h2c_config(app, config)HTTP/2 in the clear, and nothing else
    serve_graceful(app, config, handle=)the same loop, with a ShutdownHandle that drains before it closes
    @quic.serve_config(app, config, cert, ..)HTTP/1.1, HTTP/2 and HTTP/3 under one certificate
    @cli.run(app)a whole server program, configured from the command line

    Every entry point runs the same loop, so they share the limits, keep-alive, pipelining and the WebSocket bridge. serve is the short way in; serve_config takes the record.

    async fn main {
    let app : @moonasgi.AsgiApp = (_scope, _receive, send) => {
    send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false))
    send(@moonasgi.Event::HttpResponseBody(body=b"configured\n", more_body=false))
    }
    let config = @mooncat.Config::new(
    port=12000,
    limit_concurrency=64,
    limit_max_requests=1000,
    server_headers=Map([("server", "mooncat")]),
    )
    @mooncat.serve_config(app, config)
    }

    #Command line

    @cli.run(app) reads the process arguments and serves. The flags are the ones a Python server of this shape takes, so a habit carries over; a flag the transport cannot honour answers with the reason rather than "unexpected argument".

    async fn main {
    let app : @moonasgi.AsgiApp = (_scope, _receive, send) => {
    send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false))
    send(@moonasgi.Event::HttpResponseBody(body=b"ok", more_body=false))
    }
    @cli.run(app)
    }

    $ ./app --help Usage: mooncat [options] Serve an ASGI application over HTTP/1.1, HTTP/2, HTTPS or HTTP/3. Options: -h, --help Show help information. -V, --version Show version information. --[no-]date-header stamp a Date header on every response --[no-]server-header stamp a Server header --[no-]proxy-headers believe X-Forwarded-* from the allowed peers --[no-]access-log log one line per request --dual-stack accept IPv4 and IPv6 on one socket --h2c speak HTTP/2 in the clear instead of HTTP/1.1 --http3 serve HTTP/3 on UDP beside HTTPS, on the same port --host <host> address to bind [env: MOONCAT_HOST] --port <port> port to bind [env: MOONCAT_PORT] --limit-concurrency <limit-concurrency> refuse with 503 above this many in flight --timeout-keep-alive <timeout-keep-alive> seconds an idle keep-alive connection is held --lifespan <lifespan> auto, on or off --ssl-certfile <ssl-certfile> PEM certificate; serves HTTPS --ssl-keyfile <ssl-keyfile> PEM private key; serves HTTPS $ ./app --uds /tmp/app.sock mooncat: --uds: the async transport binds TCP only

    @cli.parse(argv) returns the plan without opening anything, for a program that wants to look before it serves.

    #Configuration

    Config holds what the flags set. Every field has a default, so only what differs is written.

    FieldDefaultWhat it does
    host, port"127.0.0.1", 12000where the listener binds
    max_connectionsnoneabove this, a client waits at the socket
    limit_concurrencynoneabove this, the request is answered 503
    limit_max_requestsnonethe server retires once it has served this many
    timeout_keep_alive5000 msan idle keep-alive connection is closed after this
    timeout_handshake60000 msa TLS handshake that has not finished is dropped
    max_head_size16384a request head past this is answered 431
    server_headers{"server": "mooncat"}added to every response
    date_headertruestamp a date on every response
    root_path""the prefix something in front has stripped
    lifespanAutoAuto, On or Off
    cleartexttrueon a TLS port, serve connections that start without a handshake
    proxy_headerstruebelieve forwarded headers from forwarded_allow_ips
    workers1processes on one port, on Linux
    ws16 MiB messages, 32 queued, 20 s pingsthe WebSocket side
    h2100 streams, 64 KiB fields, 16 KiB frames, 1 MiB windowsthe HTTP/2 side

    #HTTP/3

    @quic.serve_config runs the TCP listener and the UDP one under the same lifespan, limits and shutdown, and advertises the UDP port on every response with alt-svc. The QUIC half is native only, and one process: a UDP port cannot yet be shared between workers.

    #Packages

    PackageWhat is in itWhat it costs to link
    mooncatthe entry points, re-exportedall of them
    mooncat/configConfig, the access and error log, the HTTP datethe log, the date
    mooncat/lifespanthe lifespan protocol—
    mooncat/proxyforwarded headers from a trusted proxy—
    mooncat/servethe accept loop, HTTP/1.1, HTTP/2, WebSocket, TLS, workersthe transport and the protocol codecs
    mooncat/clithe command line, and run(app)the package above, plus the argument parser
    mooncat/quicHTTP/3: the UDP socket, the datagram loop, the streamsQUIC and its TLS

    #Examples

    Each directory under src/examples/ is a program that runs:

    moon run src/examples/01-hello --target native moon run src/examples/09-tour --target native

    01-hello serves one route on port 12000. 09-tour starts real servers, drives them with real clients and prints what came back, then stops. 11-event-log wraps an application in a middleware that prints every ASGI message it sees, one JSON line each; the playground is recorded from it.

    #Targets

    Serving runs where the async sockets are: the native and wasm backends, and the suite runs on both. On js and wasm-gc the configuration, lifespan and proxy packages build and the serving packages are empty. The QUIC half is native only.

    #License

    Apache-2.0.

    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.

    Driver

    Drives an application's lifespan protocol (← uvicorn's LifespanOn).

    One invocation of the application runs under a Lifespan scope for the server's whole life: startup sends lifespan.startup and waits for the answer, shutdown sends lifespan.shutdown. state is the scope's namespace; the server gives every request scope a shallow copy, so what startup stores there reaches each request.

    mode is uvicorn's --lifespan: under Auto an application that fails on the lifespan scope, as one serving only HTTP does, is taken not to speak the protocol and the server carries on; under On that failure stops the boot; under Off nothing runs.

    H2Config

    The HTTP/2 knobs. The stream ceiling, header-list cap and frame cap are hypercorn's (h2_max_concurrent_streams, h2_max_header_list_size, h2_max_inbound_frame_size). The receive windows are Go's server's (MaxUploadBufferPerStream, MaxUploadBufferPerConnection): the protocol's own 64 KiB lets a client upload one window per round trip.

    A stream's window comes back as the application takes its body, so a client cannot get further ahead of an application than window octets on a stream and connection_window in all.

    Lifespan

    How the server runs the lifespan protocol (← uvicorn's --lifespan).

    LifespanError

    Raised when the lifespan protocol stops the boot or reports a failed shutdown: the application sent lifespan.startup.failed or lifespan.shutdown.failed, or it raised under Lifespan::On. Carries the application's message.

    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 (--ws-max-size, --ws-max-queue, --ws-ping-interval, --ws-ping-timeout, --ws-per-message-deflate), with its defaults.

    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.

    max_queue is how many received messages wait for the application. Once it is full nothing more is read from the socket, so a client that outruns its application is held back by TCP.

    ping_interval is the milliseconds between keepalive pings, and ping_timeout how long a pong may take before the connection fails with 1011. None turns either off.

    deflate accepts permessage-deflate (RFC 7692) when a client offers it.

    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.

    Several workers

    With Config::workers above 1 this process only supervises: see supervise. Each worker comes back through this same function, binds the port under SO_REUSEPORT, and serves as described here.

    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: the same serving loop as serve_config, with its lifespan, limits, logging and graceful shutdown, on a listener that speaks HTTP/2 alone.

    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_beside

    Serve over HTTPS with another listener beside the TCP one, which runs from the moment the application has started until the requests in flight everywhere have drained.

    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.

    serve_tls_server

    Serve over HTTPS with a TLS configuration built by the caller: the suites, the groups, the ALPN list and the certificates are all server's. serve_tls_config is this with the configuration read from PEM files.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io