moonasgiASGI 3.0

Guide

A guided tour

Fourteen steps, from one handler to a server carrying several applications. Each step is a program you can run, and the output under it is what it printed.

Install

Add the package to a project. There is nothing else to configure: no transport lives in here.

terminal
moon add moonbitstack/moonasgi

It brings one import name. Everything below is reachable through it.

moon.pkg
import {
  "moonbitstack/moonasgi",
}

A few chapters use a subpackage by name — @spec for the event types, @mid for middleware, @dispatch for routing, @testing for the async client, @wsgi for the adapter. Import only the ones you reach for:

moon.pkg
import {
  "moonbitstack/moonasgi",
  "moonbitstack/moonasgi/spec",
  "moonbitstack/moonasgi/mid",
  "moonbitstack/moonasgi/dispatch",
  "moonbitstack/moonasgi/testing",
  "moonbitstack/moonasgi/wsgi",
  "moonbitlang/core/encoding/utf8",
}

A first response

A handler takes a request and returns a response. The TestClient drives one without a socket, so this runs on any backend.

moonbit
let greet : @moonasgi.Handler = fn(_request) {
  @moonasgi.Response::text("hello")
}
let client = @moonasgi.TestClient::new(greet)
let response = client.get("/")
println("\{response.status} \{response.text()}")
output200 hello

Reading a request

The request carries the method, the path, the raw query string, the headers as they arrived, and the body as one buffer. The body is drained before the handler runs, however many events it took.

moonbit
let echo : @moonasgi.Handler = fn(request) {
  let query = @moonasgi.latin1_decode(request.query_string)
  @moonasgi.Response::text(
    "\{request.http_method} \{request.path}?\{query} \{request.body.length()} B",
  )
}
let client = @moonasgi.TestClient::new(echo)
println(client.get("/items?q=moonbit").text())
println(client.post("/orders", body=b"n=1").text())
outputGET /items?q=moonbit 0 B POST /orders? 3 B

Bytes arrive as Bytes, and headers stay a list of pairs, so a repeated header survives. Nothing is folded into a map behind your back.

The scope

A handler does not see everything. A mounted application needs root_path, security needs the peer address, and a feature needs to know what the server advertises. run_http_scoped hands over the whole scope instead.

moonbit
let app = fn(scope : @moonasgi.HttpScope, body : Bytes) {
  let peer = match scope.client {
    Some((host, _)) => host
    None => "-"
  }
  [
    @spec.HttpResponseStart(status=200, headers=[], trailers=false),
    @spec.HttpResponseBody(
      body=@utf8.encode("\{scope.root_path} · \{peer} · \{body.length()} B"),
      more_body=false,
    ),
  ]
}
let scope = @moonasgi.Scope::Http(
  @moonasgi.HttpScope::new(
    http_method="GET",
    path="/greet",
    root_path="/api",
    client=("203.0.113.7", 52333),
  ),
)
let out = @moonasgi.run_http_scoped(app, scope, [
  @spec.HttpRequest(body=b"hi", more_body=false),
])
for event in out {
  if event is @spec.HttpResponseBody(body~, ..) {
    println(@utf8.decode_lossy(body))
  }
}
output/api · 203.0.113.7 · 2 B

There is also run_http_app, the same call at request level, and run_http_scoped_drain, which also tells the application whether the client vanished or the body was cut short.

Streaming

A StreamingResponse sends its body in pieces, may announce hints before the headers, and may close with trailers. events lowers it to the exact sequence a server writes.

moonbit
let feed : @moonasgi.StreamHandler = fn(_request) {
  @moonasgi.StreamingResponse::new(
    status=200,
    headers=[("content-type", "text/event-stream")],
    chunks=[b"data: 233\n\n", b"data: 666\n\n", b"data: done\n\n"],
    trailers=[("x-digest", "heke1228")],
    early_hints=[["; rel=preload"], ["; rel=preload"]],
  )
}
let client = @moonasgi.TestClient::from_stream(feed)
let response = client.get("/events")
println(response.text())
println(response.trailer("x-digest").unwrap_or("-"))
println("\{response.early_hints.length()} early hints")
outputdata: 233 data: 666 data: done heke1228 2 early hints

The client reassembles the chunks into one body and keeps the trailers and hints apart, so a test can assert on any of them.

Extensions

The optional parts of the specification are typed here too: push, path send, zero-copy send, early hints, debug, trailers, and the WebSocket denial. A server advertises what it honours on the scope, and the application reads the flags.

moonbit
let extensions = @moonasgi.Extensions::none()
  .enable_push()
  .enable_pathsend()
  .enable_early_hint()
println("push=\{extensions.http_response_push} pathsend=\{extensions.http_response_pathsend}")
println("early_hint=\{extensions.http_response_early_hint}")
outputpush=true pathsend=true early_hint=true

An extension the specification does not name goes in the same place, by name, with whatever value the server wants to hand over.

moonbit
let custom = @moonasgi.Extensions::none().enable("http.fullflush")
println("present=\{custom.get("http.fullflush") is Some(_)}")
outputpresent=true

WebSocket

A WebSocket handler is three functions: what to do on connect, what to answer for a frame, what to do when the client leaves. ws_run and the client drive a whole connection in-process.

moonbit
let handler = @moonasgi.WebSocketHandler::new(
  on_receive=fn(message) {
    match message {
      Text(text) => [SendText("echo:" + text)]
      Binary(bytes) => [SendBinary(bytes)]
    }
  },
)
let app : @moonasgi.Handler = fn(_request) { @moonasgi.Response::text("") }
let session = @moonasgi.TestClient::new(app).websocket(
  path="/chat",
  handler~,
  send=[Text("hi"), Text("there")],
)
println("accepted=\{session.accepted} texts=\{session.texts().join(" ")}")
outputaccepted=true texts=echo:hi echo:there

Connect can also answer with a close code, or with a whole HTTP response when the handshake should be refused: Accept, Reject, DenyHttp.

Lifespan

Startup runs once, before any request, and seeds scope.state in place. The server copies that map onto every request scope, which is how a pool or a config reaches a handler.

moonbit
let lifecycle = @moonasgi.LifespanHandler::new(
  on_startup=fn(scope) {
    scope.state["greeting"] = Json::string("Bonjour")
    Complete
  },
)
let scope = @spec.LifespanScope::new()
let replies = @moonasgi.run_lifespan(lifecycle, Lifespan(scope), [
  @spec.LifespanStartup,
  @spec.LifespanShutdown,
])
for reply in replies {
  println(match reply {
    LifespanStartupComplete => "startup.complete"
    LifespanShutdownComplete => "shutdown.complete"
    _ => "other"
  })
}
println(match scope.state.get("greeting") {
  Some(Json::String(s)) => s
  _ => "none"
})
outputstartup.complete shutdown.complete Bonjour

A startup that returns Failed(message) ends the boot: no shutdown follows, and the message is what the server reports.

Middleware

A middleware wraps an application, and stack folds a list of them from the last to the first. The layer given first sees the request first and the response last.

moonbit
let app : @moonasgi.AsgiApp = async fn(_, receive, send) {
  ignore(receive())
  send(HttpResponseStart(status=200, headers=[("content-type", "text/plain")], trailers=false))
  send(HttpResponseBody(body=Bytes::make(4096, b'a'), more_body=false))
}
let scope = @spec.HttpScope::new(
  http_method="GET",
  path="/",
  headers=[("host", "example.com"), ("accept-encoding", "gzip")],
)
let hits = Ref(0)
let counted = @mid.Wrap(inner => {
  async fn(scope, receive, send) {
    hits.val = hits.val + 1
    inner(scope, receive, send)
  }
})
let stacked = @mid.stack(
  [
    @mid.server_errors,
    @mid.TrustedHosts::new(hosts=["example.com"]),
    @mid.Cors::new(origins=["https://app.example.com"], methods=["GET", "PUT"]),
    @mid.gzip,
    counted,
  ],
  app,
)
let answer : Array[@spec.Event] = []
stacked(
  Http(scope),
  async fn() { HttpRequest(body=b"", more_body=false) },
  async fn(event) { answer.push(event) },
)
println("layers ran \{hits.val} times")
outputlayers ran 1 times

Seven ship with the package: server_errors, trusted_hosts, cors, gzip, https_redirect, proxy_headers, body_limit. Each is a record, so a preset can be adjusted in place: { ..@mid.gzip, level: 1 }.

Several applications behind one

One server can carry an API, an MCP endpoint and a site at the same time. mount picks by path prefix and hands the chosen application the path below it; hosts picks by the Host header.

moonbit
let app_of = fn(name : String) -> @moonasgi.AsgiApp {
  async fn(_, receive, send) {
    ignore(receive())
    send(HttpResponseStart(status=200, headers=[("x-app", name)], trailers=false))
    send(HttpResponseBody(body=@utf8.encode(name), more_body=false))
  }
}
let app = @dispatch.mount([
  ("/api", app_of("api")),
  ("/mcp", app_of("mcp")),
])
let by_host = @dispatch.hosts(
  [("docs.example.com", app_of("docs"))],
  fallback=app,
)

The mounted application sees path below its prefix and root_path with the prefix added. A path nothing takes is answered 404, or a declined handshake on a WebSocket. The lifespan reaches every application: startup in order, shutdown in reverse, and a failed startup fails them all.

RequestReachesAnswer
GET /api/itemsapi_app, with path=/items200
GET /mcpmcp_app, with path=/200
GET /nowherenothing404
GET / with host: docs.example.comdocs_app200

Synchronous applications

An application written the old way takes a mapping of CGI variables, calls start_response, then yields the body. It runs on the same events as everything else.

moonbit
let legacy : @wsgi.WsgiApp = (environ, start_response) => {
  ignore(start_response("200 OK", [("Content-Type", "text/plain")]))
  [@utf8.encode("hello from "), @utf8.encode(environ.path_info)].iter()
}
let app : @moonasgi.AsgiApp = @wsgi.to_asgi(legacy)
served one GET /legacy/greet200 · 17 B · hello from /greet

Each chunk is sent as it is yielded, so a long body still streams. A WebSocket is declined, and the lifespan is answered.

Testing an application

An application written directly on AsgiApp is tested with testing. It runs the application for real, around its lifespan, and an error it raises reaches the test. Here is one application, in all three shapes, and the test that drives it.

moonbit
let app : @moonasgi.AsgiApp = async fn(scope, receive, send) {
  match scope {
    Http(hs) => {
      ignore(receive())
      let (status, body) = if hs.path == "/items" { (201, b"created") } else { (404, b"no") }
      send(HttpResponseStart(status~, headers=[], trailers=false))
      send(HttpResponseBody(body~, more_body=false))
    }
    WebSocket(_) => {
      ignore(receive())
      send(WebSocketAccept(subprotocol=None, headers=[]))
      guard receive() is WebSocketReceive(text~, ..) else { return }
      send(WebSocketSendText(text.unwrap_or("")))
      send(WebSocketClose(code=1000, reason=""))
    }
    Lifespan(_) => {
      ignore(receive())
      send(LifespanStartupComplete)
      ignore(receive())
      send(LifespanShutdownComplete)
    }
  }
}
let client = @testing.Client::new(app)
client.lifespan(() => {
  let created = client.post("/items", body=b"{}")
  println("POST /items -> \{created.status}")
  client.websocket(path="/chat", ws => {
    ws.send_text("hi")
    println("echo -> " + ws.receive_text())
  })
})
outputPOST /items -> 201 echo -> hi

To watch one exchange event by event, communicate hands you the application's inbox and outbox.

moonbit
@testing.communicate(
  app,
  @moonasgi.HttpScope::new(http_method="POST", path="/items") |> Http,
  c => {
    c.send(HttpRequest(body=b"", more_body=false))
    for _ in 0..<2 {
      println(match c.receive() {
        HttpResponseStart(status~, ..) => "http.response.start \{status}"
        HttpResponseBody(body~, ..) => "http.response.body \{body.length()} B"
        _ => "other"
      })
    }
  },
)
outputhttp.response.start 201 http.response.body 7 B

Keeping it honest

A server can check what an application emits before writing any of it to a socket. validate_events walks a stream and names the first violation.

moonbit
let scope = @moonasgi.Scope::Http(
  @moonasgi.HttpScope::new(http_method="GET", path="/"),
)
let wrong = [@spec.HttpResponseBody(body=b"x", more_body=false)]
println(@moonasgi.validate_events(scope, wrong).unwrap().describe())
outputresponse body before http.response.start

The seam also checks itself: run_conformance() drives every event, every scope field and every ordering rule through the cores.

moonbit
let report = @moonasgi.run_conformance()
println("\{report.passed}/\{report.total} checks passed")
output436/436 checks passed

HTTP/2 request lines

On a multiplexed transport the request line arrives as pseudo-headers, mixed in with the ordinary fields. Lowering them is part of the seam, so an application never sees a colon-prefixed header.

moonbit
let lowered = @moonasgi.HttpScope::from_h2_headers([
  (":method", "POST"),
  (":scheme", "https"),
  (":authority", "example.test"),
  (":path", "/items?page=2"),
  ("content-type", "application/json"),
])
match lowered {
  Ok(scope) => {
    println("\{scope.http_method} \{scope.path}")
    println(@moonasgi.latin1_decode(scope.query_string))
    println(scope.headers.iter().map(h => "\{h.0}: \{h.1}").join(", "))
  }
  Err(UnknownPseudoHeader(name)) => println("unknown " + name)
  Err(_) => println("malformed request line")
}
outputPOST /items page=2 host: example.test, content-type: application/json

A malformed set is refused with the first thing wrong with it: a missing required header, a duplicate, an unknown name, or a pseudo-header after an ordinary one.