The guide
One application, then one thing at a time: the configuration, the command line, TLS, WebSocket, HTTP/2, HTTP/3, the lifespan protocol, workers and proxy headers. Every block below compiles, and every terminal is what the run printed.
Your first server
Add the packages. An application needs the interface package as well as the server:
moon add moonbitstack/mooncat moon add moonbitstack/moonasgi
An ASGI application is an async function of three arguments: the scope of one request, a way to read the request body, and a way to write the response. The simplest one answers with a status, a header and a body:
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) }
serve binds the socket and stays in the accept loop until the task is cancelled. Run it and ask it something:
moon run src/main --target native curl -i http://127.0.0.1:12000/
$ curl --noproxy '*' -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
server and date are added by the server to every response, and both can be turned off. content-length is worked out from the body when the response is a single piece; a response in several pieces is chunked instead.
The scope
Each request arrives as a scope, which is where the application finds what it was asked. The fields below are the same on HTTP/1.1, HTTP/2 and HTTP/3 — the versions differ in how the bytes travel, not in what the application is told.
| Field | On an HTTP request | On a WebSocket |
|---|---|---|
http_method | "GET", "POST", … | — |
path · raw_path | percent-decoded, and the bytes as they came | — |
query_string | bytes, without the ? | — |
headers | lower-cased names, repeated fields kept | headers of the upgrade |
scheme | "http" or "https", after proxy headers | "ws" or "wss" |
client · server | the peer and the listener, as (host, port) | the same |
http_version | "1.0", "1.1", "2", "3" | "1.1" |
root_path | what something in front stripped | the same |
state | a shallow copy of what startup stored | the same copy |
Streaming a body
A request body is not held whole. Each receive() gives the piece that has arrived and says whether more is coming, so an upload of any size passes through in one window's worth of memory:
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } let mut size = 0 for ;; { guard receive() is HttpRequest(body~, more_body~) else { return } size = size + body.length() if !more_body { break } } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) let line = "\{http.http_method} \{http.path}: \{size} bytes\n" send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(line), more_body=false)) } @mooncat.serve(app, port=12000) }
Answering before the body is read is allowed, and common: the rest of the body is then drained or the connection is closed rather than being mistaken for the next request. Once the body has been delivered, a further receive() waits for the response to finish or the client to leave before it says http.disconnect — a streamed response that watches for the client going away is not cut short.
Configuration
serve_config takes a Config record instead of the three arguments serve unpacks. Every field has a default, so only what differs from it is written:
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)) } let config = @mooncat.Config::new( host="127.0.0.1", port=12000, limit_concurrency=64, limit_max_requests=1000, timeout_keep_alive=5000, server_headers=Map([("server", "mooncat"), ("x-app", "demo")]), ) @mooncat.serve_config(app, config) }
The fields that matter
| Field | Default | What it does |
|---|---|---|
host · port | "127.0.0.1" · 12000 | where the listener binds |
backlog | 2048 | the listen queue; recorded, not applied — the socket layer calls listen itself |
dual_stack | false | one IPv6 socket that also accepts IPv4 |
max_connections | none | above this, a client waits at the socket |
limit_concurrency | none | above this, the request is answered 503 |
limit_max_requests | none | the server retires once it has served this many |
timeout_keep_alive | 5000 ms | an idle keep-alive connection is closed after this |
timeout_handshake | 60000 ms | a TLS handshake that has not finished by then is dropped |
graceful_timeout | none | how long a drain may take before the listener closes |
max_head_size | 16384 | a request head past this is answered 431 |
server_headers | {"server": "mooncat"} | added to every response; empty to send none |
date_header | true | stamp a date on every response |
root_path | "" | the prefix something in front has stripped |
lifespan | Auto | Auto, On or Off |
cleartext | true | on a TLS port, also serve connections that do not start with a handshake |
workers | 1 | processes on one port; see below |
ws · h2 | see below | the two protocol-specific groups |
Command line
@cli.run(app) is a whole server program: it reads the process arguments, builds the configuration, and serves. Nothing else is needed in main.
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"served from the command line", more_body=false)) } @cli.run(app) }
moon run src/main --target native -- --port 9000 --log-level debug moon run src/main --target native -- --help
Every flag is optional and has the same meaning it has in a Python server of the same shape:
$ ./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 --[no-]reuse-addr set SO_REUSEADDR on the listener --dual-stack accept IPv4 and IPv6 on one socket --[no-]cleartext serve the clear beside TLS on the TLS port --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] --workers <workers> processes to serve from (Linux) [env: WEB_CONCURRENCY] --limit-concurrency <limit-concurrency> refuse with 503 above this many in flight --limit-max-requests <limit-max-requests> stop serving after this many requests --timeout-keep-alive <timeout-keep-alive> seconds an idle keep-alive connection is held --forwarded-allow-ips <forwarded-allow-ips> comma-separated peers whose forwarded headers count --lifespan <lifespan> auto, on or off --ssl-certfile <ssl-certfile> PEM certificate; serves HTTPS --ssl-keyfile <ssl-keyfile> PEM private key; serves HTTPS --ssl-ciphers <ssl-ciphers> TLS 1.3 and 1.2 suites to accept by IANA name, colon-separated, best first
A flag the transport cannot honour is answered with the reason, not with "unexpected argument". Silently ignoring it would be worse: the program would start and behave as if the option had been read.
$ ./app --uds /tmp/app.sock mooncat: --uds: the async transport binds TCP only
Reading the arguments without serving is @cli.parse(argv), which returns the plan — a Config, whether to wrap it in TLS, whether to speak h2c — and opens nothing:
fn main { let plan = @cli.parse(@env.args()[1:]) catch { err => { println("\{err}") return } } println("bind \{plan.config.bind()}") println("h2c \{plan.h2c}") println("http3 \{plan.http3}") }
TLS
serve_tls serves the same application over TLS 1.3. The certificate and the key are PEM files, read once at startup; a key that is not the certificate's is refused there rather than at the first handshake.
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"over tls", more_body=false)) } @mooncat.serve_tls( app, certificate_file="src/certs/cert.pem", // the leaf first, then any intermediates private_key_file="src/certs/key.pem", // PKCS#8, SEC1 or PKCS#1 port=12000, ) }
The listener offers h2 and http/1.1 by ALPN in the same handshake, so the client picks the version and the server follows it:
$ openssl s_client -connect 127.0.0.1:12000 -alpn h2,http/1.1
Protocol: TLSv1.3
Cipher is TLS_CHACHA20_POLY1305_SHA256
ALPN protocol: h2
Both versions come back from the same port, the same certificate and the same application:
$ curl -k --http2 https://127.0.0.1:12000/ HTTP/2 200 content-type: text/plain server: mooncat alt-svc: h3=":12000"; ma=86400 date: Mon, 02 Nov 2026 09:25:21 GMT 2 GET / 0 $ curl -k --http1.1 https://127.0.0.1:12000/ HTTP/1.1 200 OK content-type: text/plain server: mooncat alt-svc: h3=":12000"; ma=86400 date: Mon, 02 Nov 2026 09:25:22 GMT content-length: 12 1.1 GET / 0
The same port answers the clear as well, by default: a connection whose first byte is not a TLS record is served HTTP/1.1 or h2c with scheme "http". cleartext=false — --no-cleartext on the command line — makes the port TLS only.
WebSocket
An upgrade is a request like any other until the handshake completes; then the scope is a websocket scope and the same receive/send pair speaks frames. ws:// and wss:// share one path.
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is WebSocket(_) else { return } for ;; { match receive() { WebSocketConnect => send(@moonasgi.Event::WebSocketAccept(subprotocol=None, headers=[])) WebSocketReceive(text=Some(text), ..) => send(@moonasgi.Event::WebSocketSendText(text)) WebSocketReceive(bytes=Some(bytes), ..) => send(@moonasgi.Event::WebSocketSendBytes(bytes)) WebSocketDisconnect(..) => break _ => () } } } @mooncat.serve(app, port=9001) }
The handshake, recorded against that server:
$ curl -v ws://127.0.0.1:9001/ > GET / HTTP/1.1 > Host: 127.0.0.1:9001 > Upgrade: websocket > Sec-WebSocket-Version: 13 > Sec-WebSocket-Key: D/NLFJQ2oY2kcWKtAc1kUg== > Connection: Upgrade < HTTP/1.1 101 Switching Protocols < Upgrade: websocket < Connection: Upgrade < Sec-WebSocket-Accept: ujJCyRitqg7NDMxhzmBTEBtTRhI=
The framing is enforced rather than trusted: a set RSV bit, a reserved opcode, an unmasked client frame or a malformed control frame fails the connection with 1002; a message past ws.max_size is refused with 1009 before the payload is read; invalid UTF-8 in a text message fails it with 1007; a peer close is echoed as the specification asks.
Config.ws | Default | What it does |
|---|---|---|
max_size | 16 MiB | the largest message accepted |
max_queue | 32 | messages waiting for the application; full stops the read, so the client is held back by TCP |
ping_interval | 20000 ms | a keepalive ping goes out this often; None turns it off |
ping_timeout | 20000 ms | a peer that misses it is closed with 1011 |
deflate | true | accept permessage-deflate when the client offers it |
Pings are answered and not passed to the application. Rejecting the upgrade before WebSocketAccept answers 403.
HTTP/2 and h2c
serve and serve_tls already speak HTTP/2 beside HTTP/1.1 on the same port: under TLS the client names h2 in ALPN; in the clear a connection that opens with the HTTP/2 preface is served HTTP/2, which is what --http2-prior-knowledge sends. Nothing in the application changes.
$ curl --http2-prior-knowledge http://127.0.0.1:12000/ HTTP/2 200 content-type: text/plain; charset=utf-8 server: mooncat date: Mon, 02 Nov 2026 10:11:38 GMT hello from mooncat
serve_h2c is a listener that speaks HTTP/2 and nothing else — no sniffing, no HTTP/1.1 fallback:
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"h2 only", more_body=false)) } @mooncat.serve_h2c(app, host="127.0.0.1", port=12000) }
Each stream runs the application in a task of its own, so a slow handler holds up only its own stream. A request body arrives as the application asks for it, and a stream's window comes back at that pace, so a client cannot run further ahead than one window.
Config.h2 | Default | What it does |
|---|---|---|
max_streams | 100 | concurrent streams; more are refused with REFUSED_STREAM |
max_header_list | 64 KiB | fields allowed in one head |
max_frame | 16 KiB | the largest frame the server will send |
window | 1 MiB | flow-control window of one stream |
connection_window | 1 MiB | flow-control window of the connection |
The limits that apply to HTTP/1.1 apply per stream: past limit_concurrency a stream is answered 503, every stream counts toward limit_max_requests, and a connection with no stream open for timeout_keep_alive is sent a GOAWAY. A client's RST_STREAM reaches the application as http.disconnect. A failure before the response starts is a 500; after it, the stream is reset with INTERNAL_ERROR.
HTTP/3
HTTP/3 is the UDP half, at the same port number as the TCP listener. @quic.serve_config takes the same application and the same Config, plus the certificate, and runs both halves under one lifespan and one shutdown:
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) let line = "\{http.http_version} \{http.http_method} \{http.path}\n" send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(line), more_body=false)) } @quic.serve_config(app, @mooncat.Config::new(port=12000), { certificate_file: "src/certs/cert.pem", private_key_file: "src/certs/key.pem", }) }
Every HTTP/1.1 and HTTP/2 response then carries alt-svc, which is how a client learns the UDP port exists. A client that already knows asks for it directly:
$ python h3trace.py # an HTTP/3 client, ALPN h3 :status 200 content-type text/plain server mooncat alt-svc h3=":12000"; ma=86400 date Mon, 02 Nov 2026 09:25:39 GMT 3 GET / 0
The application answered "3" for http_version: it does not know or care which of the three it is being asked over. Each request stream runs in a task of its own, a body reaches the application as it asks for it, and a response is paced by what the client has acknowledged.
Lifespan
The application is invoked once before the listener binds, under a Lifespan scope, and once more on the way out. That is where a connection pool is opened and closed. Config.lifespan decides how strictly this is enforced:
| Mode | Behaviour |
|---|---|
Auto | run it; an application that fails on the lifespan scope is served anyway |
On | run it; an application that fails on it stops the server from starting |
Off | do not run it at all |
async fn main { let mut pool = "" let app : @moonasgi.AsgiApp = (scope, receive, send) => { match scope { Lifespan(_) => for ;; { match receive() { LifespanStartup => { pool = "connected" send(@moonasgi.Event::LifespanStartupComplete) } LifespanShutdown => { pool = "closed" send(@moonasgi.Event::LifespanShutdownComplete) break } _ => () } } _ => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(pool), more_body=false)) } } } @mooncat.serve_config(app, @mooncat.Config::new(port=12000, lifespan=On)) }
Whatever startup puts into the scope's state reaches every HTTP and WebSocket scope as a shallow copy, so a shared pool is reachable without a global. Shutdown runs even when the serving task is cancelled, which is what a signal sends.
Workers
workers runs the program several times and shares the port between the copies. The process that was started serves nothing itself: it supervises.
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)) } @mooncat.serve_config(app, @mooncat.Config::new(port=12000, workers=4)) }
Each worker binds the port itself and runs its own lifespan; the kernel spreads connections across them. A worker that dies is replaced, which is also how limit_max_requests retires one. A worker that cannot start ends the supervisor with exit code 3. Stopping the supervisor stops them all, each given timeout_graceful_shutdown to drain.
HTTP/3 is shared another way. moonbitlang/async opens a UDP socket in one step, so SO_REUSEPORT cannot be set on it; the supervisor holds the UDP port instead and passes datagrams to the workers over loopback. Each worker puts its number in the first byte of every connection id it issues, and the supervisor reads it from every datagram that follows; the id a client makes up for its first Initial is routed by the same byte, so a handshake never splits.
This is Linux only: only there does the kernel balance connections across several sockets bound to one port, and the process model needs to hand a child a listening socket. On other systems the setting is refused rather than quietly honoured for one process. WEB_CONCURRENCY in the environment sets the number when the flag is absent.
Proxy headers
Behind a load balancer the peer address is the balancer's, and the scheme is whatever the balancer terminated. proxy_headers makes the server believe X-Forwarded-For, X-Forwarded-Proto and Forwarded — but only from the peers named in forwarded_allow_ips. A request from anywhere else is described by its own address, however it is labelled.
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } let who = match http.client { Some((host, _port)) => "\{http.scheme} from \{host}" None => "nowhere" } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(who), more_body=false)) } @mooncat.serve_config(app, @mooncat.Config::new( port=12000, proxy_headers=true, forwarded_allow_ips=["127.0.0.1", "10.0.0.0"], )) }
* in forwarded_allow_ips trusts every peer, which is only safe when the socket is not reachable from anywhere else. proxy_rewrite is the same decision as a function, for a program that wants it outside the server.
Extensions
An HTTP scope advertises the extensions the server honours, on all three versions:
| Extension | What the server does |
|---|---|
http.response.early_hint | a 103 Early Hints goes out before the answer, one link field per link |
http.response.trailers | a response started with trailers=true is chunked and its trailers ride in the last chunk; on HTTP/2 they are the closing HEADERS frame |
tls | on a TLS connection: tls_version, cipher_suite, and the server's certificate as PEM |
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseEarlyHint(links=["</style.css>; rel=preload; as=style"])) send(@moonasgi.Event::HttpResponseStart( status=200, headers=[("content-type", "text/plain")], trailers=true, )) send(@moonasgi.Event::HttpResponseBody(body=b"body first", more_body=false)) send(@moonasgi.Event::HttpResponseTrailers(headers=[("x-checksum", "42")], more_trailers=false)) } @mooncat.serve(app, port=12000) }
A 103 is not sent to an HTTP/1.0 client, and trailers are not sent to one either: it has nowhere to put them.
Targets
Serving runs wherever the async sockets are available: the native and wasm backends, and the test suite runs on both. On js and wasm-gc the configuration, lifespan and proxy packages build and the serving packages are empty, so a program that only parses a configuration still compiles there. The QUIC half is native only.