// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Watch a request cross the seam: every inbound event the application pulls and
/// every outbound event it pushes, in the order they happen, printed as JSON.
///
/// moon run src/examples/18-trace > docs/assets/traces.json
///
/// The documentation site plays this back as an event timeline. Nothing here is
/// scripted: each scenario drives the real cores — `to_asgi`, `ws_run`,
/// `run_lifespan`, `dispatch.mount`, `wsgi.to_asgi`, the middleware stack — with
/// a `receive` and a `send` that record what passes through them.
#coverage.skip
async fn main {
@async.pause()
let scenarios : Array[Json] = [
hello(),
chunked(),
streaming(),
layers(),
routes(),
boot(),
legacy(),
socket(),
letters(),
]
println(
Json::object({ "scenarios": Json::array(scenarios) }).stringify(indent=2),
)
}
///|
/// The steps one scenario recorded, in order.
struct Rec {
steps : Array[Json]
}
///|
fn Rec::new() -> Rec {
{ steps: [], }
}
///|
fn Rec::add(
self : Rec,
lane : String,
title : String,
body : String,
note : String,
) -> Unit {
self.steps.push(
Json::object({
"lane": Json::string(lane),
"title": Json::string(title),
"body": Json::string(body),
"note": Json::string(note),
}),
)
}
///|
/// A scenario as the page needs it: what was asked, what happened, what came back.
fn scenario(
id : String,
title : String,
request : String,
blurb : String,
rec : Rec,
result : String,
) -> Json {
Json::object({
"id": Json::string(id),
"title": Json::string(title),
"request": Json::string(request),
"blurb": Json::string(blurb),
"result": Json::string(result),
"steps": Json::array(rec.steps),
})
}
///|
/// The connection over which one scenario runs: the inbound events still to be
/// pulled, and the outbound ones already pushed.
struct Driver {
inbound : Array[@spec.Event]
out : Array[@spec.Event]
rec : Rec
mut at : Int32
}
///|
fn Driver::new(inbound : Array[@spec.Event], rec : Rec) -> Driver {
{ inbound, out: [], rec, at: 0, }
}
///|
/// The `receive` an application is handed: it records the pull, and returns the
/// next event the server would have delivered.
fn Driver::receive(self : Driver) -> @spec.Receive {
async fn() {
@async.pause()
let event = self.inbound[self.at]
self.at = self.at + 1
self.rec.add(
"in",
event.kind(),
event_body(event),
"the application pulls the next event in",
)
event
}
}
///|
/// The `send` an application is handed: it records the push.
fn Driver::send(self : Driver) -> @spec.Send {
async fn(event) {
@async.pause()
self.out.push(event)
self.rec.add(
"out",
event.kind(),
event_body(event),
"the application pushes it back to the server",
)
}
}
///|
fn event_body(event : @spec.Event) -> String {
match event {
HttpRequest(body~, more_body~) =>
"body \{body.length()} B · more_body \{more_body}"
HttpResponseStart(status~, headers~, trailers~) =>
"\{status} · \{headers.length()} headers · trailers \{trailers}"
HttpResponseBody(body~, more_body~) =>
"body \{body.length()} B · more_body \{more_body}"
HttpResponseTrailers(headers~, ..) => "\{headers.length()} headers"
HttpResponsePush(path~, ..) => path
HttpResponseEarlyHint(links~) => "\{links.length()} links"
WebSocketReceive(text~, bytes~) =>
match (text, bytes) {
(Some(s), _) => "text · \{@utf8.encode(s).length()} B"
(None, Some(b)) => "binary · \{b.length()} B"
_ => ""
}
WebSocketDisconnect(code~, reason~) =>
"code \{code} · reason \{reason.unwrap_or("none")}"
WebSocketAccept(subprotocol~, headers~) =>
"subprotocol \{subprotocol.unwrap_or("none")} · \{headers.length()} headers"
WebSocketSendText(text) => "text · \{@utf8.encode(text).length()} B"
WebSocketSendBytes(bytes) => "binary · \{bytes.length()} B"
WebSocketClose(code~, reason~) => "code \{code} · \{reason}"
LifespanStartupFailed(message~) | LifespanShutdownFailed(message~) =>
message
_ => ""
}
}
///|
fn scope_line(scope : @spec.HttpScope) -> String {
"\{scope.http_method} \{scope.path} · HTTP/\{scope.http_version} · \{scope.scheme}"
}
///|
/// A `Handler` lifted onto the seam, driven once: the plainest possible request.
async fn hello() -> Json {
@async.pause()
let rec = Rec::new()
let greet : @moonasgi.Handler = fn(request) {
rec.add(
"app",
"the handler runs",
"\{request.http_method} \{request.path}",
"it sees a plain request, not a scope: method, path, headers, body",
)
@moonasgi.Response::text("hello from the seam")
}
let scope = @spec.HttpScope::new(
http_method="GET",
path="/notes",
scheme="http",
headers=[("host", "example.test"), ("accept", "text/plain")],
client=("203.0.113.7", 52333),
)
rec.add(
"scope",
"scope",
scope_line(scope),
"the server builds one scope per request and hands it over",
)
let driver = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
@moonasgi.to_asgi(greet)(Http(scope), driver.receive(), driver.send())
scenario(
"hello", "A handler answers", "GET /notes", "One handler, lifted onto the seam. The scope goes in, the body is pulled, two events come out.",
rec, "200 · 19 B · hello from the seam",
)
}
///|
/// A body that arrives in pieces: `receive` is pulled twice before the answer.
async fn chunked() -> Json {
@async.pause()
let rec = Rec::new()
let echo : @moonasgi.Handler = fn(request) {
rec.add(
"app",
"the handler runs",
"\{request.body.length()} B of body",
"the seam drained every chunk first, so the handler reads one buffer",
)
@moonasgi.Response::new(
201,
[("content-type", "application/json")],
request.body,
)
}
let scope = @spec.HttpScope::new(http_method="POST", path="/items", headers=[
("host", "example.test"),
("content-type", "application/json"),
])
rec.add(
"scope",
"scope",
scope_line(scope),
"a streamed upload, cut into two events",
)
let driver = Driver::new(
[
HttpRequest(body=b"{\"name\": ", more_body=true),
HttpRequest(body=b"\"ada\"}", more_body=false),
],
rec,
)
@moonasgi.to_asgi(echo)(Http(scope), driver.receive(), driver.send())
scenario(
"chunked", "A body arrives in pieces", "POST /items", "The client sends the body in two events. The application pulls until `more_body` is false.",
rec, "201 · 15 B · {\"name\": \"ada\"}",
)
}
///|
/// A streaming answer: early hints first, then the body in chunks, then trailers.
fn streaming() -> Json {
let rec = Rec::new()
let scope = @spec.HttpScope::new(
http_method="GET",
path="/feed",
scheme="http",
)
rec.add(
"scope",
"scope",
scope_line(scope) + " · early_hint and trailers advertised",
"the server said it honours both extensions, so the response may use them",
)
let in_flight = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
in_flight.rec.add(
"in", "http.request", "body 0 B · more_body false", "no request body to read",
)
let response = @moonasgi.StreamingResponse::new(
chunks=[b"tick 1\n", b"tick 2\n", b"tick 3\n"],
headers=[("content-type", "text/plain; charset=utf-8")],
trailers=[("x-checksum", "9f2c")],
early_hints=[["; rel=preload; as=style"]],
)
for event in response.events() {
in_flight.out.push(event)
in_flight.rec.add(
"out",
event.kind(),
event_body(event),
"lowered from the streaming response, in wire order",
)
}
scenario(
"streaming", "A streamed answer", "GET /feed", "A 103 goes out before the headers, three chunks follow, and the trailers close the body.",
rec, "200 · 3 chunks · 21 B · trailers x-checksum",
)
}
///|
/// Middleware around an application: the request goes in through every layer and
/// the answer comes back out through them in reverse.
async fn layers() -> Json {
@async.pause()
let rec = Rec::new()
let app : @spec.AsgiApp = async fn(_, receive, send) {
@async.pause()
ignore(receive())
rec.add(
"app", "the application answers", "200 · 2 KiB of text", "the innermost thing in the stack is the only one that knows about the site",
)
send(
HttpResponseStart(
status=200,
headers=[("content-type", "text/plain; charset=utf-8")],
trailers=false,
),
)
send(HttpResponseBody(body=Bytes::make(2048, b'a'), more_body=false))
}
let around = fn(name : String, layer : &@mid.Layer) {
@mid.Wrap(inner => {
let wrapped = layer.wrap(inner)
async fn(scope, receive, send) {
@async.pause()
rec.add(
"mw",
"→ " + name,
"the request enters this layer",
"each layer wraps the next, so the first given is the outermost",
)
wrapped(scope, receive, send)
rec.add(
"mw",
"← " + name,
"the answer leaves through it",
"on the way out the order reverses",
)
}
})
}
let stack : @spec.AsgiApp = @mid.stack(
[
around("ServerErrors", @mid.server_errors),
around("Gzip", @mid.gzip),
around("Cors", @mid.cors),
],
app,
)
let scope = @spec.HttpScope::new(
http_method="GET",
path="/",
scheme="https",
headers=[
("host", "example.test"),
("accept-encoding", "gzip"),
("origin", "https://app.example.test"),
],
)
rec.add(
"scope",
"scope",
scope_line(scope),
"the layers below see the same scope",
)
stack(
Http(scope),
async fn() {
@async.pause()
HttpRequest(body=b"", more_body=false)
},
async fn(event) {
@async.pause()
rec.add(
"out",
event.kind(),
event_body(event),
"the stack let it through after the layers had their turn",
)
},
)
scenario(
"layers", "Wrapped in middleware", "GET / · accept-encoding: gzip", "Server errors, gzip and CORS wrap the application. The request goes in, the compressed answer comes back out.",
rec, "200 · 34 B gzip · 2 KiB before compression",
)
}
///|
/// Several applications behind one: a path prefix picks one, a host name picks
/// another.
async fn routes() -> Json {
@async.pause()
let rec = Rec::new()
let named = fn(name : String) -> @spec.AsgiApp {
async fn(scope, receive, send) {
@async.pause()
let line = match scope {
Http(hs) => "\{hs.http_method} \{hs.path} · root_path \{hs.root_path}"
_ => ""
}
rec.add(
"app", name, line, "the dispatcher rewrote the path below the prefix and extended root_path",
)
ignore(receive())
send(
HttpResponseStart(status=200, headers=[("x-app", name)], trailers=false),
)
send(HttpResponseBody(body=@utf8.encode(name), more_body=false))
}
}
let by_path = @dispatch.mount([
("/api", named("the api")),
("/mcp", named("the mcp endpoint")),
])
let api_scope = @spec.HttpScope::new(http_method="GET", path="/api/items")
rec.add(
"scope",
"scope",
scope_line(api_scope),
"one server, three applications behind it",
)
let api = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
by_path(Http(api_scope), api.receive(), api.send())
let by_host = @dispatch.hosts(
[("docs.example.test", named("the docs host"))],
fallback=by_path,
)
let docs_scope = @spec.HttpScope::new(http_method="GET", path="/", headers=[
("host", "docs.example.test"),
])
rec.add(
"scope", "scope", "GET / · host docs.example.test", "the same server, picking on the Host header this time",
)
let docs = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
by_host(Http(docs_scope), docs.receive(), docs.send())
let unmatched = @spec.HttpScope::new(http_method="GET", path="/nowhere")
rec.add("scope", "scope", scope_line(unmatched), "a path no prefix takes")
let miss = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
by_path(Http(unmatched), miss.receive(), miss.send())
scenario(
"routes", "One server, several applications", "GET /api/items · GET / with host docs.example.test",
"A prefix picks the API, a host name picks the docs site, and a path no prefix takes is answered 404.",
rec, "200 the api · 200 the docs host · 404",
)
}
///|
/// Startup and shutdown across every application behind one: in order on the way
/// up, in reverse on the way down.
async fn boot() -> Json {
@async.pause()
let rec = Rec::new()
let service = fn(name : String) -> @spec.AsgiApp {
async fn(scope, receive, send) {
@async.pause()
guard scope is Lifespan(_) else { return }
guard receive() is LifespanStartup else { return }
rec.add(
"app",
name + " starts",
"opens what it needs",
"startup runs in the order the applications were given",
)
send(LifespanStartupComplete)
guard receive() is LifespanShutdown else { return }
rec.add(
"app",
name + " stops",
"releases what it opened",
"shutdown runs in reverse, so what started last stops first",
)
send(LifespanShutdownComplete)
}
}
let apps : Array[@spec.AsgiApp] = [
service("the api"),
service("the mcp endpoint"),
service("the site"),
]
rec.add(
"scope", "scope", "lifespan · spec 2.0", "one lifespan run for all three applications behind the server",
)
let driver = Driver::new([LifespanStartup, LifespanShutdown], rec)
@dispatch.lifespan(
apps,
@spec.LifespanScope::new(),
driver.receive(),
driver.send(),
)
scenario(
"boot", "Booting three applications", "lifespan.startup · lifespan.shutdown",
"One lifespan drives all three applications: startup in order, shutdown in reverse.",
rec, "startup complete · shutdown complete",
)
}
///|
/// A synchronous application adapted to the same event stream.
async fn legacy() -> Json {
@async.pause()
let rec = Rec::new()
let app : @wsgi.WsgiApp = (environ, start_response) => {
rec.add(
"app",
"the synchronous application runs",
"\{environ.request_method} \{environ.path_info}",
"it is handed CGI-style variables, not a scope, and yields its body",
)
ignore(start_response("200 OK", [("Content-Type", "text/plain")]))
[@utf8.encode("hello from "), @utf8.encode(environ.path_info)].iter()
}
let scope = @spec.HttpScope::new(
http_method="GET",
path="/legacy/greet",
root_path="/legacy",
)
rec.add(
"scope",
"scope",
scope_line(scope) + " · root_path /legacy",
"the adapter lowers the scope to the variables a synchronous application expects",
)
let driver = Driver::new([HttpRequest(body=b"", more_body=false)], rec)
@wsgi.to_asgi(app)(Http(scope), driver.receive(), driver.send())
scenario(
"legacy", "A synchronous application", "GET /legacy/greet", "An application written the old synchronous way, adapted onto the same events as everything else.",
rec, "200 · 24 B · hello from /greet",
)
}
///|
/// A WebSocket connection: the handshake, two frames each way, a close.
fn socket() -> Json {
let rec = Rec::new()
let scope = @spec.WebSocketScope::new(
path="/chat",
subprotocols=["chat.v2", "chat.v1"],
headers=[("host", "example.test")],
)
rec.add(
"scope", "scope", "websocket /chat · offers chat.v2, chat.v1", "a WebSocket scope is the http one plus the subprotocols on offer",
)
let inbound : Array[@spec.Event] = [
WebSocketConnect,
WebSocketReceive(text=Some("hello"), bytes=None),
WebSocketReceive(text=None, bytes=Some(b"\x01\x02")),
WebSocketDisconnect(code=1000, reason=Some("done")),
]
for event in inbound {
rec.add(
"in",
event.kind(),
event_body(event),
"one event per thing the client did",
)
}
let handler = @moonasgi.WebSocketHandler::new(
on_connect=fn(scope) {
let chosen = scope.select_subprotocol(["chat.v2"])
rec.add(
"app",
"the handler accepts",
"subprotocol \{chosen.unwrap_or("none")}",
"it picks one of the subprotocols the client offered",
)
Accept(subprotocol=chosen, headers=[])
},
on_receive=fn(message) {
match message {
Text(text) => {
rec.add(
"app", "the handler echoes", text, "every frame is answered on the spot",
)
[SendText(text)]
}
Binary(bytes) => {
rec.add(
"app",
"the handler acknowledges",
"\{bytes.length()} B binary",
"binary frames are answered in kind",
)
[SendBinary(bytes)]
}
}
},
on_disconnect=fn(code, reason) {
rec.add(
"app",
"the handler sees the disconnect",
"code \{code} · reason \{reason.unwrap_or("none")}",
"the close code and reason arrive together",
)
[]
},
)
for event in @moonasgi.ws_run(handler, WebSocket(scope), inbound) {
rec.add(
"out",
event.kind(),
event_body(event),
"the WebSocket core lowers what the handler returned",
)
}
scenario(
"socket", "A WebSocket conversation", "GET /chat · Upgrade: websocket", "A handshake with a subprotocol, one text frame and one binary frame each way, then a clean close.",
rec, "accepted chat.v2 · 2 frames echoed",
)
}
///|
fn h2_error(error : @spec.Http2HeaderError) -> String {
match error {
MissingMethod => "missing :method"
MissingScheme => "missing :scheme"
MissingPath => "missing :path"
EmptyPath => "empty :path"
DuplicatePseudoHeader(name) => "duplicate " + name
UnknownPseudoHeader(name) => "unknown " + name
PseudoHeaderAfterRegular(name) => name + " after an ordinary field"
}
}
///|
/// The same request on a multiplexed transport: the request line arrives as
/// pseudo-headers and is lowered into the typed scope.
fn letters() -> Json {
let rec = Rec::new()
let block : Array[(String, String)] = [
(":method", "POST"),
(":scheme", "https"),
(":authority", "example.test"),
(":path", "/items?page=2"),
("content-type", "application/json"),
]
for header in block {
rec.add(
"in",
header.0,
header.1,
if header.0.has_prefix(":") {
"a pseudo-header: part of the request line, not a header the application sees"
} else {
"an ordinary field, passed through as it came"
},
)
}
match @spec.HttpScope::from_h2_headers(block) {
Ok(scope) => {
rec.add("scope", "http_method", scope.http_method, "lowered from :method")
rec.add(
"scope",
"path",
scope.path,
"lowered from :path, query split off",
)
rec.add(
"scope",
"query_string",
@moonasgi.latin1_decode(scope.query_string),
"the part after ? in :path",
)
rec.add(
"scope",
"headers",
"\{scope.headers.length()} · host \{scope.headers[0].1}",
"host is synthesised from :authority; no colon-prefixed header survives",
)
}
Err(error) =>
rec.add("scope", "rejected", h2_error(error), "a malformed set is named")
}
scenario(
"letters", "A request on HTTP/2", "POST /items?page=2 · :authority example.test",
"An HTTP/2 or HTTP/3 request carries its request line as pseudo-headers. Lowering them is part of the seam.",
rec, "POST /items · page=2 · host example.test",
)
}