指南

从一个应用开始,逐项讲配置、命令行、TLS、WebSocket、HTTP/2、HTTP/3、lifespan、多 worker 与代理头。代码都能编译,终端块都是运行输出。

第一个服务器

装两个包:服务器与接口定义。

shell
moon add moonbitstack/mooncat
moon add moonbitstack/moonasgi

ASGI 应用是接受三个参数的异步函数:scope、读请求体、写响应。

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 绑定套接字,停在接受循环里,直到任务被取消。

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 与 date 由服务器添加,都可关闭。单段响应按正文算 content-length,分段响应改用 chunked。

scope 里有什么

请求以 scope 到达。下列字段在三代 HTTP 上一致。

字段HTTP 请求上WebSocket 上
http_method"GET"、"POST" …—
path · raw_path百分号解码后的,以及原样的字节—
query_string字节,不含 ?—
headers名字小写,重复的字段都留着升级请求的 headers
scheme"http" 或 "https",经代理头改写"ws" 或 "wss"
client · server对端与监听端,都是 (host, port)同上
http_version"1.0"、"1.1"、"2"、"3""1.1"
root_path前面的东西剥掉的前缀同上
statestartup 存下的东西的浅拷贝同一份拷贝

流式读取请求体

请求体不整块收下:每次 receive() 给出已到的片段,并说明后面还有没有。

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)
}

可以先作答再读完:剩余部分被读掉或关闭连接,不会当成下一个请求。请求体交完后,receive() 等到响应结束或客户端离开才给 http.disconnect。

配置

serve_config 收一个 Config。字段都有默认值,只写不同的:

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)
}

要紧的那些字段

字段默认作用
host · port"127.0.0.1" · 12000监听地址
backlog2048监听队列;只记录不生效——套接字层自己调 listen
dual_stackfalse一个 IPv6 套接字同时收 IPv4
max_connections无超过之后,客户端在套接字上等
limit_concurrency无超过之后,请求直接回 503
limit_max_requests无答满这么多请求后服务器退休
timeout_keep_alive5000 毫秒空闲的 keep-alive 连接到点就关
timeout_handshake60000 毫秒到点还没握完手的 TLS 连接丢掉
graceful_timeout无排空在飞的请求最多等多久
max_head_size16384请求头超过这个数回 431
server_headers{"server": "mooncat"}加进每个响应;留空就什么都不加
date_headertrue给每个响应盖 date
root_path""前面的东西剥掉的前缀
lifespanAutoAuto、On 或 Off
cleartexttrueTLS 端口上,也服务不以握手开头的连接
workers1一个端口上的进程数,见下文
ws · h2见下文两个协议各自的开关组

命令行

@cli.run(app) 就是完整的服务器程序:读参数、拼配置、服务。

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

参数都可省略,含义与同形状的 Python 服务器一致:

recorded — 调了 run(app) 的程序的 --help
$ ./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

做不到的参数回原因,不说「未知参数」。

recorded
$ ./app --uds /tmp/app.sock
mooncat: --uds: the async transport binds TCP only

只读参数用 @cli.parse(argv),返回计划,不打开任何东西:

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 用 TLS 1.3 服务同一个应用。PEM 启动时读一次,私钥与证书不配对就在此时报错。

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", // 先叶证书,再中间证书
    private_key_file="src/certs/key.pem",  // PKCS#8、SEC1 或 PKCS#1
    port=12000,
  )
}

握手时 ALPN 给出 h2 与 http/1.1,客户端挑一个:

recorded — openssl s_client 对着这个监听器
$ 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

同端口、同证书、同应用:

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

同端口默认也答明文:首字节不是 TLS 记录按 HTTP/1.1 或 h2c 处理,scheme 为 "http"。cleartext=false(--no-cleartext)只说 TLS。

WebSocket

握手完成前是一次普通请求;之后 scope 变成 websocket,同一对 receive/send 改说帧。ws:// 与 wss:// 共用一条路径。

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)
}

握手实录:

recorded — curl 8.21 说 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=

帧有校验:RSV 置位、保留操作码、未掩码帧、畸形控制帧都以 1002 断开;超过 ws.max_size 在读载荷前以 1009 拒绝;非法 UTF-8 以 1007 失败;关闭帧按规范回显。

Config.ws默认作用
max_size16 MiB能收下的最大消息
max_queue32等着应用的讯息;满了就停止读取,客户端被 TCP 挡住
ping_interval20000 毫秒保活 ping 的间隔;None 关掉
ping_timeout20000 毫秒对端没按时回 pong 就以 1011 关闭
deflatetrue客户端提出 permessage-deflate 时接受

ping 由服务器应答,不交给应用。升级前拒绝回 403。

HTTP/2 与 h2c

serve 与 serve_tls 已在同端口上说 HTTP/2:TLS 下由 ALPN 点名 h2,明文下看前言。应用不用改。

recorded — 同一个应用,无 TLS,无升级
$ 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 只说 HTTP/2,不嗅探、不回落:

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)
}

每条流一个任务,慢处理器只拖住自己。请求体随索取而到,窗口按这个节奏还回。

Config.h2默认作用
max_streams100并发流数;多出来的以 REFUSED_STREAM 拒掉
max_header_list64 KiB一个头块允许的字段
max_frame16 KiB服务器愿意发的最大帧
window1 MiB单条流的流控窗口
connection_window1 MiB整条连接的流控窗口

限额按流算:超过 limit_concurrency 回 503,每条流计入 limit_max_requests,timeout_keep_alive 内无流则发 GOAWAY。客户端的 RST_STREAM 变成 http.disconnect。响应前出错回 500,之后用 INTERNAL_ERROR 重置。

HTTP/3

HTTP/3 在 UDP 上,端口号与 TCP 相同。@quic.serve_config 收同一应用、同一份 Config 与证书:

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",
  })
}

此后每个响应带 alt-svc,客户端由此知道 UDP 端口:

recorded — 一个 QUIC 客户端对着同一个端口
$ python h3trace.py   # 一个 HTTP/3 客户端,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

应用给 http_version 答的是 "3"。每条流一个任务,响应按客户端已确认的部分推进。

lifespan

应用在监听前与退出前各被调用一次,都在 Lifespan scope 下。连接池在这里开关。Config.lifespan 决定严格程度:

模式行为
Auto照跑;在 lifespan scope 上失败的应用照样服务
On照跑;在它上面失败,服务器就不启动
Off完全不跑
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))
}

startup 放进 state 的东西以浅拷贝进入每个 scope。服务任务被取消时 shutdown 照样跑。

多 worker

workers 把程序跑若干份,共享一个端口。被启动的进程自己不服务,只监督。

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))
}

每个 worker 自己绑定端口、跑 lifespan,内核分摊连接。死掉的被替换,limit_max_requests 也这样退休。起不来的 worker 让监督进程以退出码 3 结束。停监督进程即停全部,各自按 timeout_graceful_shutdown 排空。

HTTP/3 换一种方式共享。moonbitlang/async 打开 UDP 套接字是一步完成的,设不上 SO_REUSEPORT,所以 UDP 端口由监督进程持有,经本机回环把数据报交给各 worker。每个 worker 把自己的编号写进它发放的每个连接 ID 的首字节,监督进程从此后的每个数据报里读出它;客户端为首个 Initial 自选的 ID 按同一字节分派,握手不会被拆开。

仅 Linux:只有它的内核在绑到同一端口的套接字间分摊连接。别的系统会拒绝该设置。缺省时由 WEB_CONCURRENCY 决定。

代理头

负载均衡后面,对端地址是均衡器的。proxy_headers 让服务器相信 X-Forwarded-For、X-Forwarded-Proto、Forwarded,但只对 forwarded_allow_ips 里的对端。

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"],
  ))
}

* 表示相信所有对端,仅在套接字不可外达时安全。proxy_rewrite 是同一判断的函数形式。

扩展

HTTP scope 声明支持的扩展,三代一致:

扩展服务器做什么
http.response.early_hint答案之前先发 103 Early Hints,每个链接一个 link 字段
http.response.trailers以 trailers=true 开始的响应改用 chunked,尾字段在最后一块里;HTTP/2 上是收尾的 HEADERS 帧
tlsTLS 连接上:tls_version、cipher_suite,以及服务器证书的 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)
}

HTTP/1.0 客户端两者都收不到。

目标平台

有异步套接字即可服务:native 与 wasm,测试两边都跑。js 与 wasm-gc 上只有配置、lifespan、代理三个包,服务包为空。QUIC 只在 native。