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:

shell
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:

src/main.mbt
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:

shell
moon run src/main --target native
curl -i http://127.0.0.1:12000/
recorded
$ 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.

FieldOn an HTTP requestOn a WebSocket
http_method"GET", "POST", …—
path · raw_pathpercent-decoded, and the bytes as they came—
query_stringbytes, without the ?—
headerslower-cased names, repeated fields keptheaders of the upgrade
scheme"http" or "https", after proxy headers"ws" or "wss"
client · serverthe peer and the listener, as (host, port)the same
http_version"1.0", "1.1", "2", "3""1.1"
root_pathwhat something in front strippedthe same
statea shallow copy of what startup storedthe 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:

src/main.mbt
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:

src/main.mbt
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

FieldDefaultWhat it does
host · port"127.0.0.1" · 12000where the listener binds
backlog2048the listen queue; recorded, not applied — the socket layer calls listen itself
dual_stackfalseone IPv6 socket that also accepts IPv4
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 by then is dropped
graceful_timeoutnonehow long a drain may take before the listener closes
max_head_size16384a request head past this is answered 431
server_headers{"server": "mooncat"}added to every response; empty to send none
date_headertruestamp a date on every response
root_path""the prefix something in front has stripped
lifespanAutoAuto, On or Off
cleartexttrueon a TLS port, also serve connections that do not start with a handshake
workers1processes on one port; see below
ws · h2see belowthe 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.

src/main.mbt
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)
}
shell
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:

recorded — the --help of a program that calls 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
  --[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.

recorded
$ ./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:

src/main.mbt
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.

src/main.mbt
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:

recorded — openssl s_client against the listener
$ 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:

recorded
$ 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.

src/main.mbt
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:

recorded — curl 8.21 speaking ws://
$ 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.wsDefaultWhat it does
max_size16 MiBthe largest message accepted
max_queue32messages waiting for the application; full stops the read, so the client is held back by TCP
ping_interval20000 msa keepalive ping goes out this often; None turns it off
ping_timeout20000 msa peer that misses it is closed with 1011
deflatetrueaccept 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.

recorded — the same application, no TLS, no upgrade
$ 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:

src/main.mbt
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.h2DefaultWhat it does
max_streams100concurrent streams; more are refused with REFUSED_STREAM
max_header_list64 KiBfields allowed in one head
max_frame16 KiBthe largest frame the server will send
window1 MiBflow-control window of one stream
connection_window1 MiBflow-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:

src/main.mbt
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:

recorded — a QUIC client against the same port
$ 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:

ModeBehaviour
Autorun it; an application that fails on the lifespan scope is served anyway
Onrun it; an application that fails on it stops the server from starting
Offdo not run it at all
src/main.mbt
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.

src/main.mbt
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.

src/main.mbt
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:

ExtensionWhat the server does
http.response.early_hinta 103 Early Hints goes out before the answer, one link field per link
http.response.trailersa response started with trailers=true is chunked and its trailers ride in the last chunk; on HTTP/2 they are the closing HEADERS frame
tlson a TLS connection: tls_version, cipher_suite, and the server's certificate as PEM
src/main.mbt
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.