mooncat

An ASGI 3.0 server for MoonBit. It accepts a connection, turns each request into a scope, a receive and a send, and drives your application — over HTTP/1.1, HTTP/2, HTTP/3 and WebSocket, on one port.

$ moon add moonbitstack/mooncat
The mooncat mark: a cat peering over two server bars, against a crescent moon.

What it doesthe whole job, in one picture

mooncat owns the half of a web server that is not the protocol: the listening socket, the connection, the request that arrives on it in pieces, and the answer that leaves in pieces. It hands the application a scope describing the request and two callables — one to read the body, one to write the response. Everything else, from routing to templates, belongs to the application.

A client connects to mooncat, which presents the request to the application as a scope, a receive and a send. client curl, a browser mooncat accept loop · one task per connection h1.1 h2 h3 ws tls port, limits, keep-alive, lifespan, shutdown the scope, receive and send an app is called with your application routing, JSON, templates TCP · UDP scope receive() reads the request body, send() writes the answer The application never sees a socket. mooncat never sees a route.
One connection, one task. The application is called with a scope; the body arrives as it is written.

One port, every version

The listener decides as the connection opens. Under TLS the choice is made in the handshake; in the clear it is the first bytes on the wire. Either way the request lands in the same place.

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 handshake with h2 and http/1.1 offeredthe 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 its own task
HTTP/3the same port number on UDPone scope per stream, advertised by alt-svc
ws · wssUpgrade: websocket, at the starta websocket scope: connect, accept, receive, send, close

Quick start

shell
moon add moonbitstack/mooncat
moon add moonbitstack/moonasgi

An application is a function of three arguments. It answers with two events:

src/main.mbt
/// A server that greets whoever connects.
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)
}
shell
moon run src/main --target native
curl -i http://127.0.0.1:12000/
recorded — curl against the server above
$ 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

A recorded runmoon run src/examples/09-tour

The tour example starts real servers on real sockets, drives them with real clients, prints what came back and stops. Nothing below is typed by hand.

moon run src/examples/09-tour --target native
$ moon run src/examples/09-tour --target native
# configuration, as the command line reads it
  bind                 127.0.0.1:12001
  keep-alive           30000 ms
  retire after         100
  trusted proxies      127.0.0.1, 10.0.0.0
  tls                  false

# lifespan: startup and shutdown bracket the listener
  order                startup, serving, shutdown

# an app on a socket, answered by a real client
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Started server process on http://127.0.0.1:12031
INFO:     127.0.0.1:56255 - "GET /echo/moonbit HTTP/1.1" 200 OK
  GET /echo/moonbit    200 moonbit
INFO:     127.0.0.1:56256 - "GET / HTTP/1.1" 200 OK
  GET /                200 root
  server header        mooncat
  date header          true

# keep-alive, and the request limit that retires a server
  three requests       200, 200, 200
  the fourth           refused, the server retired

# a trusted proxy is believed, an untrusted one is not
  believed             https from 203.0.113.7
  ignored              http from 127.0.0.1

# websocket: connect, echo, close
  sent 'one', got      echo one
  sent 'two', got      echo two

# http/2 over cleartext
  listening h2c on     127.0.0.1:12036
  try it with          curl --http2-prior-knowledge http://127.0.0.1:12036/echo/h2

# graceful shutdown drains what is in flight
  stop asked for while it was still running
  the slow request     200 slow

The packages

A program that answers only over TCP links none of the UDP half. The split follows what a package costs to carry, not how it reads.

PackageWhat it is for
mooncatthe entry points, re-exported, so @mooncat.serve is all a small program needs
mooncat/configConfig, the access and error log, the HTTP date
mooncat/lifespanstartup before the listener, shutdown after it
mooncat/proxythe client and scheme a trusted proxy reports
mooncat/servethe accept loop, HTTP/1.1, HTTP/2, WebSocket, TLS, workers
mooncat/clithe command line, and run(app) as a whole program
mooncat/quicHTTP/3: the UDP socket, the datagram loop, and the streams

Serving runs where the async sockets do — the native and wasm backends — and the test suite runs on both. The QUIC half is native only.