ASGI 3.0 for MoonBit: the interface between servers and frameworks.
moon add moonbitstack/moonasgilet greet : @moonasgi.Handler = fn(_request) {
@moonasgi.Response::text("hello")
}
let client = @moonasgi.TestClient::new(greet)
let response = client.get("/")
assert_eq(response.status, 200)
assert_eq(response.text(), "hello")| Name | What it is |
|---|---|
| Scope | Http · WebSocket · Lifespan — the connection, as typed data |
| Event | Every message in both directions, one variant each, extensions included |
| Receive | async () -> Event — pull the next event in |
| Send | async (Event) -> Unit — push the next event out |
| AsgiApp | async (Scope, Receive, Send) -> Unit — an application |
| Handler | (Request) -> Response — the short form a server lifts onto AsgiApp |
| Middleware | (Handler) -> Handler — one handler wrapped around another |
| Core | Drives |
|---|---|
| run_http | a Handler over a scope and an inbound event list |
| run_http_scoped | a scope-level application, with the full HttpScope |
| run_http_stream | a StreamingResponse, lowered to the wire order |
| ws_run | a WebSocket connection, connect to close |
| run_lifespan | startup and shutdown, in the order a server runs them |
let scope = @moonasgi.Scope::Http(
@moonasgi.HttpScope::new(http_method="GET", path="/"),
)
let inbound = [@moonasgi.Event::HttpRequest(body=b"", more_body=false)]
let events = @moonasgi.run_http(greet, scope, inbound)
assert_eq(events.length(), 2)
assert_eq(@moonasgi.validate_events(scope, events) is None, true)let plain = @moonasgi.Response::text("hello")
let created = @moonasgi.Response::json(status=201, Json({ "id": 233 }))
let hand_built = @moonasgi.Response::new(204, [("x-trace", "1")], b"")
assert_eq(plain.header("content-type"), Some("text/plain; charset=utf-8"))
assert_eq(created.header("content-type"), Some("application/json"))
assert_eq(hand_built.status, 204)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: done\n\n"],
trailers=[("x-digest", "9f2c")],
early_hints=[["</style.css>; rel=preload"]],
)
}
let stream_client = @moonasgi.TestClient::from_stream(feed)
let stream = stream_client.get("/events")
assert_eq(stream.body, b"data: 233\n\ndata: done\n\n")
assert_eq(stream.trailer("x-digest"), Some("9f2c"))
assert_eq(stream.early_hints.length(), 1)let chat = @moonasgi.WebSocketHandler::new(
on_connect=fn(_scope) {
@ws.WsAccept::Accept(subprotocol=Some("chat.v1"), headers=[])
},
on_receive=fn(message) {
match message {
@ws.WsMessage::Text(text) => [@ws.WsSend::SendText("echo:" + text)]
@ws.WsMessage::Binary(bytes) => [@ws.WsSend::SendBinary(bytes)]
}
},
)
let session = @moonasgi.TestClient::new(greet).websocket(
path="/chat",
handler=chat,
send=[@ws.WsMessage::Text("hi")],
)
assert_eq(session.accepted, true)
assert_eq(session.subprotocol, Some("chat.v1"))
assert_eq(session.texts(), ["echo:hi"])let raw = @moonasgi.ws_run(
chat,
@moonasgi.Scope::WebSocket(@moonasgi.WebSocketScope::new(path="/chat")),
[
@moonasgi.Event::WebSocketConnect,
@moonasgi.Event::WebSocketReceive(text=Some("hi"), bytes=None),
@moonasgi.Event::WebSocketDisconnect(code=1000, reason=None),
],
)
assert_eq(@moonasgi.validate_ws_response(raw) is None, true)let lifecycle = @moonasgi.LifespanHandler::new(
on_startup=fn(scope) {
scope.state["db_pool"] = Json::string("pool://main")
Complete
},
on_shutdown=fn(_scope) { Complete },
)
let boot = @moonasgi.LifespanScope::new()
let replies = @moonasgi.run_lifespan(
lifecycle,
@moonasgi.Scope::Lifespan(boot),
[@moonasgi.Event::LifespanStartup, @moonasgi.Event::LifespanShutdown],
)
assert_eq(replies.length(), 2)
assert_eq(boot.state.get("db_pool"), Some(Json::string("pool://main")))| Middleware | What it does | Default |
|---|---|---|
| cors | Answers preflights, echoes the origin, exposes headers | no origin, GET |
| gzip | Compresses a single-message body that is large enough | 500 octets, level 9 |
| trusted_hosts | Refuses other Host values, redirects to www. | any host, redirect on |
| https_redirect | http to https, ws to wss, by 307 | — |
| proxy_headers | Restores the client and scheme from a trusted proxy | trusts 127.0.0.1 |
| body_limit | Answers 413 over the limit, by length or as it streams | 1 MiB |
| rate_limit | Answers 429 with Retry-After when a client's token bucket is empty | 2 a second, bursts of 8, by client address |
| server_errors | A 500 when the answer has not started, then the error on | no detail |
| sessions | Signs the session into a cookie, and reads it back | 14 days, SameSite=Lax |
| auth | Resolves an identity once per connection, onto the scope | no backend |
let app : @moonasgi.AsgiApp = async fn(_scope, receive, send) {
ignore(receive())
send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false))
send(@moonasgi.Event::HttpResponseBody(body=b"ok", more_body=false))
}
let stacked = @mid.stack(
[
@mid.server_errors,
@mid.TrustedHosts::new(hosts=["example.com"]),
@mid.cors,
@mid.gzip,
],
app,
)
let statuses : Array[Int32] = []
for host in ["example.com", "evil.example"] {
stacked(
@moonasgi.Scope::Http(
@moonasgi.HttpScope::new(
http_method="GET",
path="/",
headers=[("host", host)],
),
),
async fn() { @moonasgi.Event::HttpRequest(body=b"", more_body=false) },
async fn(event) {
if event is @moonasgi.Event::HttpResponseStart(status~, ..) {
statuses.push(status)
}
},
)
}
assert_eq(statuses, [200, 400])let sessions : @mid.Sessions = @mid.Sessions::new(
secret=@utf8.encode("a secret only the server knows"),
)
let login : @moonasgi.AsgiApp = async fn(scope, receive, send) {
ignore(receive())
@mid.session(scope)["user"] = Json::string("ada")
send(
@moonasgi.Event::HttpResponseStart(
status=303,
headers=[("location", "/me")],
trailers=false,
),
)
send(@moonasgi.Event::HttpResponseBody(body=b"", more_body=false))
}let backend : &@mid.Backend = @mid.BackendFn(fn(scope) {
match @mid.session(scope).get("user") {
Some(name) => @mid.Identity::of(Json::object(Map([("name", name)])))
None => @mid.Identity::anonymous()
}
})
let guarded = sessions.wrap(@mid.Auth::new(backend).wrap(login))fn const_app(body : String) -> @moonasgi.AsgiApp {
async fn(_scope, receive, send) {
ignore(receive())
send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false))
send(
@moonasgi.Event::HttpResponseBody(
body=@utf8.encode(body),
more_body=false,
),
)
}
}let mounted = @dispatch.mount([
("/api", const_app("api")),
("/mcp", const_app("mcp")),
])
let by_host = @dispatch.hosts(
[("docs.example.com", const_app("docs"))],
fallback=mounted,
)
let bodies : Array[String] = []
mounted(
@moonasgi.Scope::Http(
@moonasgi.HttpScope::new(http_method="GET", path="/api/items"),
),
async fn() { @moonasgi.Event::HttpRequest(body=b"", more_body=false) },
async fn(event) {
if event is @moonasgi.Event::HttpResponseBody(body~, ..) {
bodies.push(@utf8.decode_lossy(body))
}
},
)
assert_eq(bodies, ["api"])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 legacy_app : @moonasgi.AsgiApp = @wsgi.to_asgi(legacy)let environ = @wsgi.Environ::of(
@moonasgi.HttpScope::new(http_method="GET", path="/old"),
b"",
)
assert_eq(environ.path_info, "/old")
assert_eq(environ.to_map().get("REQUEST_METHOD"), Some("GET"))let extensions = @moonasgi.Extensions::none().enable_push().enable_early_hint()
assert_eq(extensions.http_response_push, true)
assert_eq(extensions.http_response_trailers, false)
let custom = @moonasgi.Extensions::none().enable("http.fullflush")
assert_eq(custom.get("http.fullflush") is Some(_), true)let parsed = @moonasgi.HttpScope::from_h2_headers([
(":method", "POST"),
(":scheme", "https"),
(":authority", "example.test"),
(":path", "/items?page=2"),
])
match parsed {
Ok(scope) => {
assert_eq(scope.http_method, "POST")
assert_eq(scope.path, "/items")
assert_eq(scope.query_string, b"page=2")
}
Err(_) => abort("the pseudo-headers did not parse")
}let report = @moonasgi.run_conformance()
assert_eq(report.ok(), true)
assert_eq(report.failures.length(), 0)let minimal : @moonasgi.AsgiApp = async fn(_scope, receive, send) {
ignore(receive())
send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false))
send(@moonasgi.Event::HttpResponseBody(body=b"", more_body=false))
}
let stepped : Array[String] = []
@testing.communicate(
minimal,
@moonasgi.Scope::Http(@moonasgi.HttpScope::new(http_method="GET", path="/")),
c => {
c.send(@moonasgi.Event::HttpRequest(body=b"", more_body=false))
while true {
let event = c.receive() catch {
@testing.Finished => break
e => raise e
}
stepped.push(event.kind())
}
},
)
assert_eq(stepped, ["http.response.start", "http.response.body"])| Package | What is in it |
|---|---|
| moonbitstack/moonasgi | Everything below, re-exported under one import |
| …/spec | The scopes, the events, the extensions, the HTTP/2 lowering |
| …/http | Request, Response, StreamingResponse, the HTTP cores |
| …/ws | WebSocketHandler, WsAccept, WsMessage, ws_run |
| …/lifespan | LifespanHandler and run_lifespan |
| …/client | TestClient for handlers and WebSocket sessions |
| …/cookie | Request cookie pairs, Set-Cookie lines, and a jar |
| …/mid | The middleware stack and the layers above |
| …/dispatch | mount, hosts and a shared lifespan |
| …/wsgi | A synchronous application on the same events |
| …/testing | A real AsgiApp driven in-process, event by event |
| …/conform | The conformance harness and the ordering rules |
moon run src/examples/00-hello| # | Example | Shows |
|---|---|---|
| 00 | hello | A handler, a test client, and the same handler on the raw seam |
| 01 | responses | Text, JSON, bytes, custom status and headers |
| 02 | request-methods | Every verb, the request line, a streamed body |
| 03 | middleware | A chain of handler transformers, outermost first |
| 04 | streaming | Chunks, trailers and 103 Early Hints |
| 05 | scope | The full scope a framework binds to |
| 06 | extensions | Capability flags and the extension events |
| 07 | websocket | Accept, reject, deny, echo |
| 08 | lifespan | Startup seeding state, and a failed boot |
| 09 | validate | Naming the first ordering violation |
| 10 | http2 | Lowering a HEADERS block |
| 11 | headers | The latin-1 header codec |
| 12 | conformance | The harness, run |
| 13 | legacy | The older double-callable shape |
| 14 | stack | Real middleware around a real application |
| 15 | testing | An async application tested in-process |
| 16 | dispatch | Three applications behind one |
| 17 | wsgi | A synchronous application, mounted |
| 18 | trace | Every event of a request, in order |
| 19 | session | A session in a signed cookie, and an identity read from it |
fn guarantee_single_callable(app : AsgiApplication) -> (async (Scope, async () -> Event, async (Event) -> Unit) -> Unit)fn latin1_decode(bytes : Bytes) -> Stringfn latin1_encode(s : String) -> Bytesfn percent_decode(target : String) -> BytesInstall
Download zipASGI 3.0 for MoonBit: the interface between servers and frameworks.