crescent

Crescent: A web framework for MoonBit.

http
server
web
framework
async
ai-friendly
moon add bobzhang/crescent@0.11.0
Download zip
Author
Version
0.11.0
License
Apache-2.0
Last updated
23 days ago
Downloads
446

Dependencies

README

#Crescent

A web framework for MoonBit. Type-safe and AI-agent friendly.

Hard fork of oboard/mocket by oboard. Credits at the bottom.

Targets: native and wasm1 (--target wasm). The HTTP server, fetch, WebSocket, and static-file packages use moonbitlang/async on both targets. JS and wasm-gc are not supported. Native remains the preferred target in moon.mod.

#Install

moon add bobzhang/crescent

#Hello World

///|
async fn main {
let app = @crescent.App()
app.get("/", _ => "Hello, Crescent!")
app.serve(port=4000)
}

moon run . --target native # Visit http://localhost:4000


#Building a Todo API

This walkthrough builds a complete REST API step by step.

#Define your types

Types with derive(ToJson, FromJson) are your API contract. The same types compile to native (backend) and JS (frontend) — no code generation needed.

///|
struct Todo {
id : Int
title : String
done : Bool
} derive(ToJson, FromJson)

///|
struct CreateTodo {
title : String
} derive(FromJson)

///|
#warnings("-unnecessary_annotation")
struct JsonDemoUser {
name : String
age : Int
} derive(ToJson, FromJson, Eq)

///|
test "json_value sets content-type and serializes body" {
let user = JsonDemoUser::{ name: "Alice", age: 30 }
let res = HttpResponse::ok().json_value(user)
debug_inspect(
res,
content=(
#|{
#| status_code: OK,
#| headers: { "Content-Type": "application/json; charset=utf-8" },
#| cookies: {},
#| raw_body: ...,
#|}
),
)

debug_inspect(
@utf8.decode(res.raw_body),
content=(
#|"{\"name\":\"Alice\",\"age\":30}"
),
)
}

#Wire up the routes

All handlers automatically map errors to JSON — bad JSON returns 400, raise HttpError(...) returns a structured error, anything else returns 500. You never write error-handling boilerplate.

///|
#warnings("-unused_value")
fn build_app() -> @crescent.App {
let app = @crescent.App()
let todos : Array[Todo] = [{ id: 1, title: "Learn MoonBit", done: false }]
let next_id = Ref::Ref(2)

// List all
app.get("/api/todos", _ => HttpResponse::ok().json_value(todos))

// Get by ID — require_param_int auto-returns 400 for "abc"
app.get("/api/todos/:id", event => {
let id = event.require_param_int("id")
for todo in todos {
if todo.id == id {
return HttpResponse::ok().json_value(todo)
}
}
raise HttpError::HttpError(NotFound, "todo \{id} not found")
})

// Create — event.json() auto-returns 400 for invalid JSON
app.post("/api/todos", event => {
let input : CreateTodo = event.json()
let todo = Todo::{ id: next_id.val, title: input.title, done: false }
next_id.val 1
todos.push(todo)
HttpResponse::created().json_value(todo)
})

// Health check — get_raw for handlers that never raise
app.get_raw("/health", _ => "ok")

app
}

#Add middleware

Register middleware before routes. They execute in onion order (first registered = outermost layer).

///|
fn _build_app() -> @crescent.App {
let app = @crescent.App()

// Security headers on every response
app.use_middleware(@middleware.security_headers())

// Unique request ID for distributed tracing
app.use_middleware(@middleware.request_id())

// Request logging
app.use_middleware((event, next) => {
let start = @async.now()
let res = next()
let ms = @async.now() - start
println("\{event.req.http_method} \{event.req.url} \{ms}ms")
res
})

// Auth only on /api routes
app.use_middleware(
(event, next) => {
match event.req.get_header("Authorization") {
Some(_) => next()
None => HttpResponse::unauthorized()
}
},
base_path="/api",
)

// ... register routes ...
app
}

///|
async test "security headers middleware" {
let app = @crescent.App()
app.use_middleware(@middleware.security_headers())
app.get_raw("/test", fn(_) noraise { "ok" })
let client = @test_client.TestClient(app)
let res = client.get("/test")
guard res.headers
is { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", .. } else {
fail("missing expected security headers")
}
}

///|
async test "request ID middleware" {
let app = @crescent.App()
app.use_middleware(@middleware.request_id())
app.get_raw("/test", fn(_) noraise { "ok" })
let client = @test_client.TestClient(app)
let res = client.get("/test")
assert_true(res.headers.get("X-Request-Id") is Some(_))
}

#Test without a network

TestClient dispatches requests in-process. No ports, no sockets, fast CI.

///|
async test "create a todo" {
let app = build_app()
let client = @test_client.TestClient(app)

let res = client.post("/api/todos", body=b"{\"title\":\"Write tests\"}")
assert_eq(res.status, Created)

let todo : Todo = res.body_json()
assert_eq(todo.title, "Write tests")
assert_eq(todo.done, false)
}

///|
async test "invalid ID returns 400" {
let client = @test_client.TestClient(build_app())
let res = client.get("/api/todos/abc")
assert_eq(res.status, BadRequest)
}

///|
async test "missing todo returns 404" {
let client = @test_client.TestClient(build_app())
let res = client.get("/api/todos/999")
assert_eq(res.status, NotFound)
}

///|
async test "bad JSON returns 400" {
let client = @test_client.TestClient(build_app())
let res = client.post("/api/todos", body=b"not json")
assert_eq(res.status, BadRequest)
}

///|
async test "security headers are present" {
let client = @test_client.TestClient(build_app())
let res = client.get("/health")
assert_eq(res.headers.get("X-Content-Type-Options"), Some("nosniff"))
}

///|
#warnings("-unnecessary_annotation")
struct TodoInput {
title : String
} derive(FromJson, ToJson)

///|
async test "typed handler auto-maps errors" {
let app = @crescent.App()
app.post("/todos", event => {
let input : TodoInput = event.json()
HttpResponse::created().json_value(input)
})
let client = @test_client.TestClient(app)

// Valid request
let res = client.post("/todos", body=b"{\"title\":\"Learn MoonBit\"}")
assert_eq(res.status, Created)

// Invalid JSON -> automatic 400
let res2 = client.post("/todos", body=b"not json")
assert_eq(res2.status, BadRequest)
}

#Start the server

///|
async fn main {
let app = build_app()
println("Listening on http://localhost:4000")
app.serve(port=4000)
}

Try it:

curl localhost:4000/api/todos curl -X POST localhost:4000/api/todos -d '{"title":"Write docs"}' curl localhost:4000/api/todos/1 curl localhost:4000/api/todos/abc # 400: must be a valid integer curl -X POST localhost:4000/api/todos -d 'not json' # 400: parse error curl localhost:4000/health # "ok"


#CRUD with resource()

When your API follows REST conventions, register all 5 routes in one call:

app.resource("/api/todos", ResourceConfig(
list = fn(_) { HttpResponse::ok().json_value(all_todos()) },
get = fn(e) {
let id = e.require_param_int("id")
HttpResponse::ok().json_value(find_todo(id))
},
create = fn(e) {
let input : CreateTodo = e.json()
HttpResponse::created().json_value(insert_todo(input))
},
update = fn(e) {
let id = e.require_param_int("id")
let input : CreateTodo = e.json()
HttpResponse::ok().json_value(update_todo(id, input))
},
delete = fn(e) {
let id = e.require_param_int("id")
delete_todo(id)
HttpResponse::no_content()
},
))

This registers GET /api/todos, GET /api/todos/:id, POST /api/todos, PUT /api/todos/:id, DELETE /api/todos/:id. All handlers omitted from ResourceConfig(...) are simply not registered.

///|
#warnings("-unnecessary_annotation")
struct ResItem {
id : Int
name : String
} derive(ToJson, FromJson, Eq, Debug)

///|
#warnings("-unnecessary_annotation")
struct CreateResItem {
name : String
} derive(FromJson, ToJson)

///|
async test "resource CRUD" {
let items : Array[ResItem] = [{ id: 1, name: "Alpha" }]
let app = @crescent.App()
app.resource(
"/items",
ResourceConfig(
list=_ => HttpResponse::ok().json_value(items),
get=event => {
let id = event.require_param_int("id")
for item in items {
if item.id == id {
return HttpResponse::ok().json_value(item)
}
}
raise HttpError::HttpError(NotFound, "not found")
},
create=event => {
let input : CreateResItem = event.json()
let item = ResItem::{ id: 2, name: input.name }
items.push(item)
HttpResponse::created().json_value(item)
},
),
)
let client = @test_client.TestClient(app)

// List
let res = client.get("/items")
assert_eq(res.status, OK)
let list : Array[ResItem] = res.body_json()
assert_eq(list.length(), 1)

// Create
let res2 = client.post("/items", body=b"{\"name\":\"Beta\"}")
assert_eq(res2.status, Created)
let created : ResItem = res2.body_json()
assert_eq(created.name, "Beta")

// Get by ID
let res3 = client.get("/items/1")
assert_eq(res3.status, OK)
let item : ResItem = res3.body_json()
// TODO(upstream): change the bound of `assert_eq` from `Show` to `Debug`
debug_inspect(
item,
content=(
#|{ id: 1, name: "Alpha" }
),
)

// Not found
let res4 = client.get("/items/999")
assert_eq(res4.status, NotFound)
}


#Route Groups

Share a path prefix and middleware across related routes:

app.group("/api/v1", fn(api) {
api.use_middleware(require_auth())

api.get("/users", _ => HttpResponse::ok().json_value(users))
api.get("/users/:id", fn(event) {
let id = event.require_param_int("id")
HttpResponse::ok().json_value(find_user(id))
})
api.post("/users", fn(event) {
let user : CreateUser = event.json()
HttpResponse::created().json_value(save_user(user))
})
})
// Routes: GET /api/v1/users, GET /api/v1/users/:id, POST /api/v1/users
// require_auth() only runs for /api/v1/* requests

#Route Patterns

PatternExample URLCaptures
/users/users(exact match)
/users/:id/users/42event.param("id") = "42"
/users/:id/posts/:pid/users/1/posts/99two params
/files/*/files/readme.txtone segment in _
/static/**/static/css/main.cssany depth in _

Matching uses a radix tree — O(path length), not O(number of routes).

///|
test "compiled route matching" {
let route = @router.CompiledRoute("/users/:id/posts/:postId")
assert_eq(
route.match_path("/users/42/posts/99"),
Some({ "id": "42", "postId": "99" }),
)
assert_eq(route.match_path("/users/42"), None)
}

///|
test "static routes are flagged" {
let static_route = @router.CompiledRoute("/api/health")
assert_true(static_route.is_static)
let dynamic_route = @router.CompiledRoute("/api/:version")
assert_true(dynamic_route.is_static == false)
}


#WebSocket

app.ws("/chat", fn(event) {
match event {
Open(peer) => {
println("Connected: \{peer.to_string()}")
peer.subscribe("chat-room")
}
Message(peer, Text(msg)) =>
peer.publish("chat-room", msg) // broadcast to all subscribers
Message(peer, Binary(data)) =>
peer.binary(data) // echo binary back
Close(peer) =>
println("Disconnected: \{peer.to_string()}")
}
})

WebSocketPeer methods: text(msg), binary(data), subscribe(channel), unsubscribe(channel), publish(channel, msg).

#Static Files

app.static_assets("/assets", @static_file.StaticFileProvider(path="./public"))

Features: ETag caching, If-Modified-Since / If-None-Match support, Accept-Encoding content negotiation, path traversal protection, directory index fallback (index.html, index.htm, ...).

#trait ServeStaticProvider

///|
pub(open) trait ServeStaticProvider {
async fn get_meta(Self, String) -> StaticAssetMeta?
async fn get_contents(Self, String) -> &Responder
fn get_type(Self, String) -> String?
fn get_encodings(Self) -> Map[String, String]
fn get_index_names(Self) -> Array[String]
fn get_fallthrough(Self) -> Bool
}

static_assets accepts any type that implements ServeStaticProvider. The bundled @static_file.StaticFileProvider serves from the filesystem, but custom providers can pull from S3, an embedded asset bundle, a zip file, a CDN cache, etc. The path argument to each method is the asset's URL path (already stripped of the mount prefix and resolved against any index filenames).

MethodPurpose
get_metaResolve the path to asset metadata (size, mtime, ETag); None means "not found"
get_contentsProduce the response body for the resolved asset
get_typeReturn the Content-Type for the path (None skips the header)
get_encodingsProvider-wide Content-Encoding → variant suffix map (e.g. gzip.gz)
get_index_namesFilenames to try when the request points at a directory
get_fallthroughIf true, a miss falls through to the next route instead of 404

#Cookies

// Set with attributes
event.res.set_cookie("session", "abc123",
max_age=3600, http_only=true, same_site=Lax)

// Read
let user = match event.req.get_cookie("session") {
Some(cookie) => find_user_by_session(cookie.value)
None => anonymous_user()
}

// Delete (sets Max-Age=0)
event.res.delete_cookie("session")

///|
test "set and format cookie" {
let res = @crescent.HttpResponse(status_code=OK)
res.set_cookie(
"session",
"abc123",
max_age=3600,
http_only=true,
same_site=Lax,
)
assert_true(res.cookies.get("session") is Some(_))
}

#HTTP Client (Fetch)

Make outbound HTTP requests from your handlers:

app.get("/api/weather/:city", fn(event) {
let city = event.require_param("city")
let res = @fetch.get("https://api.weather.example/v1/\{city}")
let weather : WeatherData = res.read_body()
HttpResponse::ok().json_value(weather)
})

Methods: @fetch.get, @fetch.post, @fetch.put, @fetch.patch, @fetch.delete, @fetch.head. All accept optional data, headers, credentials, and mode parameters.

#CORS

// Allow all origins (default)
app.use_middleware(@cors.handle_cors())

// Restrict to specific origin
app.use_middleware(@cors.handle_cors(
origin="https://myapp.com",
methods="GET,POST",
credentials=true,
max_age=3600,
))

#Server Configuration

#Request limits

app.serve(port=4000, options=NativeServeOptions(
max_connections=1000, // concurrent connection limit
max_request_body_bytes=1_048_576, // 1MB body size limit (413 if exceeded)
request_body_read_timeout_ms=5000, // 5s read timeout (408 if exceeded)
))

#WebSocket options

app.serve(port=4000, options=NativeServeOptions(
websocket_max_message_bytes=65536, // max inbound message size
websocket_outgoing_queue_capacity=100, // outbound buffer per connection
websocket_overflow_policy=DropOldest, // or DropLatest
websocket_read_timeout_ms=30000, // close idle connections after 30s
))

#Graceful shutdown

let shutdown = @async.Queue()

// In another task: shutdown.put(()) to stop the server
app.serve(port=4000, shutdown~)

#Serve on an existing server

let addr = @socket.Addr::parse("0.0.0.0:4000")
let server = @http.Server(addr, reuse_addr=true)
app.serve_on(server)


#Response Helpers

HttpResponse::ok() // 200
HttpResponse::created() // 201
HttpResponse::no_content() // 204
HttpResponse::bad_request() // 400
HttpResponse::unauthorized() // 401
HttpResponse::forbidden() // 403
HttpResponse::not_found() // 404
HttpResponse::error(BadRequest, "message") // JSON error body
HttpResponse::redirect("/new-path") // 301
HttpResponse::redirect_temporary("/temp") // 302
HttpResponse::redirect_307("/preserve") // 307 (preserves method)
HttpResponse::redirect_308("/permanent") // 308 (permanent, preserves method)

// Fluent chaining
HttpResponse::ok()
.header("Cache-Control", "max-age=3600")
.json_value(data)

///|
test "response helpers" {
let ok = HttpResponse::ok()
assert_eq(ok.status_code, OK)

let created = HttpResponse::created()
assert_eq(created.status_code, Created)

let not_found = HttpResponse::not_found()
assert_eq(not_found.status_code, NotFound)

let no_content = HttpResponse::no_content()
assert_eq(no_content.status_code, NoContent)

let bad_request = HttpResponse::bad_request()
assert_eq(bad_request.status_code, BadRequest)
}

///|
test "fluent response building" {
let res = HttpResponse::ok()
.header("X-Custom", "value")
.header("Cache-Control", "max-age=3600")
guard res.headers
is { "X-Custom": "value", "Cache-Control": "max-age=3600", .. } else {
fail("expected fluent headers to be set")
}
}

///|
test "redirect helpers" {
let r301 = HttpResponse::redirect("/new")
assert_eq(r301.status_code, MovedPermanently)
assert_eq(r301.headers.get("Location"), Some("/new"))

let r302 = HttpResponse::redirect_temporary("/temp")
assert_eq(r302.status_code, Found)
}

#Error Handling

All get/post/put/patch/delete handlers catch errors automatically:

What you writeWhat the client gets
raise HttpError(BadRequest, "invalid email")400 {"error":{"status":400,"message":"invalid email"}}
raise HttpError(NotFound, "not found")404 with JSON body
event.json() on bad input400 with parse error message
event.require_param_int("id") on "abc"400 "must be a valid integer"
Any unhandled error500 Internal Server Error

For handlers that should never raise (health checks, plain text), use get_raw:

app.get_raw("/health", fn(_) noraise { "ok" })

For custom error responses, use try_json:

app.post("/users", fn(event) {
match event.try_json() {
Ok(user) => HttpResponse::ok().json_value(user)
Err(msg) => HttpResponse::error(BadRequest, "Invalid user: \{msg}")
}
})

#Built-in Middleware

Middleware implementations live in the bobzhang/crescent/middleware sub-package.

MiddlewareWhat it does
@middleware.security_headers()X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 0, Referrer-Policy: strict-origin-when-cross-origin
@middleware.request_id()Adds X-Request-Id header; preserves incoming IDs for distributed tracing. Access via event.request_id()
@middleware.rate_limit(requests_per_window~, window_ms~)Fixed-window rate limiter. Returns 429 Too Many Requests with a Retry-After header when the limit is exceeded.
@cors.handle_cors()Full CORS support: preflight OPTIONS handling, configurable origins/methods/credentials

Writing custom middleware:

///|
fn rate_limiter() -> Middleware {
let count = Ref(0)
fn(event, next) {
count.val 1
if count.val > 100 {
return HttpResponse::error(TooManyRequests, "slow down")
}
next()
}
}


#API Quick Reference

#Parameters

MethodReturnsOn missing/invalid
event.param("name")String?None
event.param_int("id")Int?None
event.param_int64("id")Int64?None
event.require_param("name")Stringraises 400
event.require_param_int("id")Intraises 400
event.require_param_int64("id")Int64raises 400

///|
test "param and param_int" {
let event = Event::{
req: HttpRequest(Get, "/", {}, raw_body=b""),
res: HttpResponse(status_code=OK),
params: { "id": "42", "name": "alice" },
}
assert_eq(event.param("name"), Some("alice"))
assert_eq(event.param_int("id"), Some(42))
assert_eq(event.param("missing"), None)
}

///|
test "require_param raises on missing" {
let event = Event::{
req: HttpRequest(Get, "/", {}, raw_body=b""),
res: HttpResponse(status_code=OK),
params: {},
}
@test.assert_raise(() => event.require_param_int("id"))
}

#Body & Query

MethodReturnsNotes
event.json[T]()TRaises on invalid JSON
event.try_json[T]()Result[T, String]For custom error handling
event.req.body[T]()TVia BodyReader trait (String, Bytes, Json)
event.req.get_query("key")String?URL-decoded, cached
event.req.query_params()Map[String, String]All query params, cached
event.req.path()StringPath without query string, cached
event.req.content_type()String?Content-Type header value
event.request_id()String?Requires @middleware.request_id() middleware

///|
#warnings("-unnecessary_annotation")
struct ReadmeCreateUser {
name : String
age : Int
} derive(FromJson)

///|
test "json parsing from request body" {
let req = @crescent.HttpRequest(
Post,
"/users",
{},
raw_body=b"{\"name\":\"Bob\",\"age\":25}",
)
let user : ReadmeCreateUser = req.json()
assert_eq(user.name, "Bob")
assert_eq(user.age, 25)
}

///|
test "try_json returns Result" {
let req = @crescent.HttpRequest(Post, "/", {}, raw_body=b"not json")
let result : Result[ReadmeCreateUser, String] = req.try_json()
assert_true(result is Err(_))
}

///|
test "path extracts from request target" {
let req = @crescent.HttpRequest(Get, "/api/users?q=test", {}, raw_body=b"")
let path = req.path()
assert_eq(path, "/api/users")
}

///|
test "query_params cached and decoded" {
let req = @crescent.HttpRequest(
Get,
"/search?q=hello%20world&lang=en",
{},
raw_body=b"",
)
assert_eq(req.get_query("q"), Some("hello world"))
assert_eq(req.get_query("lang"), Some("en"))
assert_eq(req.get_query("missing"), None)
}

#HttpMethod Enum

Pattern match on the request method — no string comparisons:

match event.req.http_method {
Get => "read"
Post | Put | Patch => "write"
Delete => "delete"
_ => "other"
}

///|
test "HttpMethod round-trip" {
let meth : HttpMethod = Post
assert_eq(meth.to_method_string(), "POST")
assert_eq(HttpMethod::from_string("POST"), Post)
}

///|
test "HttpMethod pattern matching" {
let req = @crescent.HttpRequest(Get, "/", {}, raw_body=b"")
let label = match req.http_method {
Get => "read"
Post => "write"
_ => "other"
}
assert_eq(label, "read")
}

#Performance

  • Radix tree routing — O(path length) dynamic route lookup
  • Pre-compiled patterns — route templates parsed at registration, not per request
  • Cached parsing — path and query string parsed once per request
  • Zero-alloc headers — case-insensitive ASCII comparison without string allocation
  • Direct byte outputBytes and HttpResponse skip the intermediate buffer

#Packages

bobzhang/crescent — Core: routing, middleware, serving, WebSocket bobzhang/crescent/httputil — HTTP protocol: headers, dates, URL encoding bobzhang/crescent/cors — CORS middleware bobzhang/crescent/fetch — HTTP client bobzhang/crescent/static_file — Static file provider (filesystem) bobzhang/crescent/test_client — In-process test client (no network I/O) bobzhang/crescent/uri — RFC 3986 URI parser

#Credits

Crescent is a hard fork of oboard/mocket by oboard. The original framework established the core architecture: Express-style routing, onion middleware, WebSocket support, static file serving, and the async-native design built on moonbitlang/async.

#License

Apache-2.0

#
BodyReader

Trait for types that can be deserialized from an HTTP request body.

#
HttpMethod

HTTP request methods as a type-safe enum.

Using this enum instead of raw strings prevents typos like "DLETE" or "OPTONS" and enables pattern matching in handlers.

#
HttpRequest

An incoming HTTP request containing the method, URL, headers, and body.

#
HttpResponse

An outgoing HTTP response containing the status code, headers, cookies, and body.

#
NativeWebSocketOverflowPolicy

Policy for handling a full outbound WebSocket message queue.

#
Responder

Trait for types that can be sent as an HTTP response body.

#
StatusCode

HTTP status codes as registered with IANA. See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml

#
WebSocketAggregatedMessage

A fully assembled WebSocket message, either text or binary.

#
WebSocketEvent

Events delivered to a WebSocket handler: open, message, or close.

#
WebSocketHandler

Handler function type for WebSocket route events.

#
WebSocketPeer

Represents a connected WebSocket client with its connection ID, subscribed channels, and route parameters captured from the upgrade URL.

#
ServeStaticProvider

pub(open) trait ServeStaticProvider {
async fn get_meta(Self, id : String) -> StaticAssetMeta? noraise
async fn get_contents(Self, id : String) -> &
Responder
noraise
fn get_type(Self, ext : String) -> String?
fn get_encodings(Self) -> Map[String, String]
fn get_index_names(Self) -> Array[String]
fn get_fallthrough(Self) -> Bool
}

Trait for providing static file serving capabilities, including asset lookup, content retrieval, and MIME type resolution.

#
ExecError

pub suberror ExecError derive(
Debug
)

Error type for execution failures such as subprocess errors.

#
HttpError

pub(all) suberror HttpError {
HttpError(
StatusCode
, String)
} derive(
Debug
)

Error type for handlers that want automatic HTTP error responses.

#
IOError

pub suberror IOError derive(
Debug
)

Error type for I/O operations such as file reads or writes.

#
NativeServeError

pub suberror NativeServeError {
InvalidMaxConnections(Int)
InvalidMaxRequestBodyBytes(Int)
InvalidRequestBodyReadTimeoutMs(Int)
InvalidHandlerTimeoutMs(Int)
InvalidShutdownTimeoutMs(Int)
InvalidWebSocketMaxMessageBytes(Int)
InvalidWebSocketOutgoingQueueCapacity(Int)
InvalidWebSocketReadTimeoutMs(Int)
} derive(ToJson,
Debug
)

Error type for invalid native server configuration values.

#
NetworkError

pub suberror NetworkError derive(
Debug
)

Error type for network-related failures such as connection errors.

#
App

pub struct App {
// private fields
}

The main application type that holds route mappings, middleware, and WebSocket handlers.

Routing state is split by access pattern:
  • static_routesMap[method, Map[path, handler]] for O(1) static lookup
  • dynamic_routes — radix tree for O(path_length) dynamic lookup
  • route_keys — flat list of (method, path) for introspection via routes()

#
App::App

fn App::App(base_path? : String) -> App

Creates a new App application instance with an optional base path prefix.

#
App::all

fn App::all(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a handler matching all HTTP methods with automatic error-to-JSON mapping.

#
App::all_raw

fn App::all_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise handler that matches all HTTP methods.

#
App::connect

fn App::connect(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a CONNECT handler with automatic error-to-JSON mapping.

#
App::connect_raw

fn App::connect_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise CONNECT handler.

#
App::delete

fn App::delete(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a DELETE handler with automatic error-to-JSON mapping.

#
App::delete_raw

fn App::delete_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise DELETE handler.

#
App::dispatch

async fn App::dispatch(self : App, http_method : String, url : String, headers : Map[String, String], body : Bytes) -> (
StatusCode
, Map[String, String], Bytes)

Dispatches a synthetic HTTP request through the full routing + middleware pipeline and returns (status_code, headers, body_bytes).

#
App::get

fn App::get(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a GET handler with automatic error-to-JSON mapping.

#
App::get_raw

fn App::get_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise GET handler. For most use cases, prefer get().

#
App::group

fn App::group(self : App, base_path : String, configure : (App) -> Unit) -> Unit

Creates a route group with a shared base path prefix, merging its routes and middleware into the app.

#
App::has_not_found_handler

fn App::has_not_found_handler(self : App) -> Bool

Returns true if any custom not-found handler has been registered, either as the catch-all via set_not_found_handler or via a group.

#
App::head

fn App::head(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a HEAD handler with automatic error-to-JSON mapping.

#
App::head_raw

fn App::head_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise HEAD handler.

#
App::on

fn App::on(self : App, http_method : String, path : String, handler : HttpHandler) -> Unit

Registers a route handler for the given HTTP method and path.

#
App::options

fn App::options(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers an OPTIONS handler with automatic error-to-JSON mapping.

#
App::options_raw

fn App::options_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise OPTIONS handler.

#
App::patch

fn App::patch(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a PATCH handler with automatic error-to-JSON mapping.

#
App::patch_raw

fn App::patch_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise PATCH handler.

#
App::post

fn App::post(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a POST handler with automatic error-to-JSON mapping.

#
App::post_raw

fn App::post_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise POST handler.

#
App::put

fn App::put(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a PUT handler with automatic error-to-JSON mapping.

#
App::put_raw

fn App::put_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise PUT handler.

#
App::resource

fn App::resource(self : App, path : String, config : ResourceConfig) -> Unit

Registers standard REST routes (list, get, create, update, delete) for a resource path.

#
App::routes

fn App::routes(self : App) -> Iter[(String, String)]

Returns an iterator over the registered route keys as (method, path) pairs.

#
App::serve

async fn App::serve(self : App, port~ : Int, shutdown? :
Queue
[Unit], options? : NativeServeOptions) -> Unit

Starts serving HTTP requests on the given port. max_connections, if present, limits the number of client connections handled in parallel by the underlying @http.Server. max_request_body_bytes, if present, rejects oversized request bodies with 413 Request Entity Too Large. request_body_read_timeout_ms, if present, rejects slow request bodies with 408 Request Timeout. websocket_max_message_bytes, if present, closes websocket connections with 1009 MessageTooBig when an inbound message exceeds that many bytes. websocket_outgoing_queue_capacity, if present, bounds buffered outbound websocket messages per connection. websocket_overflow_policy, if present, chooses whether a full outbound websocket queue drops the oldest or latest message. websocket_read_timeout_ms, if present, closes websocket connections after that many milliseconds waiting for the next inbound message. When shutdown is supplied, serving stops once the queue receives a unit or is closed. See serve_on for shutdown semantics.

#
App::serve_on

async fn App::serve_on(self : App, server :
Server
, shutdown? :
Queue
[Unit], options? : NativeServeOptions) -> Unit

Starts serving HTTP requests on the given server. max_connections, if present, limits the number of client connections handled in parallel by the underlying @http.Server. max_request_body_bytes, if present, rejects oversized request bodies with 413 Request Entity Too Large. request_body_read_timeout_ms, if present, rejects slow request bodies with 408 Request Timeout. websocket_max_message_bytes, if present, closes websocket connections with 1009 MessageTooBig when an inbound message exceeds that many bytes. websocket_outgoing_queue_capacity, if present, bounds buffered outbound websocket messages per connection. websocket_overflow_policy, if present, chooses whether a full outbound websocket queue drops the oldest or latest message. websocket_read_timeout_ms, if present, closes websocket connections after that many milliseconds waiting for the next inbound message. When shutdown is supplied, serving stops once the queue receives a unit or is closed. shutdown.put(()) wakes one waiter; shutdown.close() broadcasts to all waiters. When shutdown_timeout_ms is set, shutdown is graceful: the server stops accepting new connections and waits up to that many milliseconds for in-flight requests to complete before force-cancelling. Otherwise shutdown is cancellation-based (active requests are aborted immediately).

#
App::set_not_found_handler

fn App::set_not_found_handler(self : App, handler : HttpHandler) -> Unit

Sets a custom handler for 404 Not Found responses.

#
App::static_assets

fn App::static_assets(self : App, path : String, provider : &ServeStaticProvider) -> Unit

Mounts a static asset provider at the given path, serving files with ETag/Last-Modified caching and content negotiation.

#
App::trace

fn App::trace(self : App, path : String, handler : async (Event) -> &
Responder
) -> Unit

Registers a TRACE handler with automatic error-to-JSON mapping.

#
App::trace_raw

fn App::trace_raw(self : App, path : String, handler : HttpHandler) -> Unit

Registers a noraise TRACE handler.

#
App::use_middleware

fn App::use_middleware(self : App, middleware : Middleware, base_path? : String) -> Unit

Adds a middleware to the app, optionally scoped to a base path prefix.

#
App::ws

fn App::ws(self : App, path : String, handler :
WebSocketHandler
) -> Unit

Registers a WebSocket handler for the given path, matched regardless of HTTP method.

Dynamic patterns (:param, *, **) are inserted into the same radix tree implementation used for HTTP routes, giving them consistent precedence: static > param > wildcard > globstar. Re-registering the same path overrides the previous handler.

#
App::ws_runtime_ids

fn App::ws_runtime_ids(self : App) -> Array[String]

Returns all WebSocket runtime IDs scoped to this App instance, in the form <app_id>/serve-<n> (one per active serve_on/serve invocation). Used in tests to verify runtime registration and cleanup; production code should not need this.

#
Event

pub(all) struct Event {
req :
HttpRequest

res :
HttpResponse

params : Map[String, StringView]
}

The event passed to each HTTP handler, bundling the request, response, and route parameters.

#
Event::json

fn[T :
FromJson
] Event::json(self : Event) -> T raise

Parses the request body as JSON and deserializes into type T. Shorthand for event.req.json().

#
Event::param

fn Event::param(self : Event, name : String) -> StringView?

Returns a route parameter value as a String, or None if not found.

The value is NOT URL-decoded — it's returned exactly as it appeared in the URL. For a decoded value (e.g. hello%20worldhello world), use param_decoded().

#
Event::param_decoded

fn Event::param_decoded(self : Event, name : String) -> String?

Returns a URL-decoded route parameter value, or None if not found.

For example, if the URL is /search/hello%20world and the route is /search/:query, this returns Some("hello world").

#
Event::param_int

fn Event::param_int(self : Event, name : String) -> Int?

Returns a route parameter parsed as an Int, or None if not found or not a valid integer.

#
Event::param_int64

fn Event::param_int64(self : Event, name : String) -> Int64?

Returns a route parameter parsed as an Int64, or None if not found or not valid.

#
Event::request_id

fn Event::request_id(self : Event) -> String?

Returns the request ID for this event, or None if the request_id middleware from bobzhang/crescent/middleware is not active.

#
Event::require_param

fn Event::require_param(self : Event, name : String) -> String raise

Returns a route parameter as a String, or raises HttpError(BadRequest) if missing.

#
Event::require_param_int

fn Event::require_param_int(self : Event, name : String) -> Int raise

Returns a route parameter parsed as an Int, or raises HttpError(BadRequest) if missing or not a valid integer.

#
Event::require_param_int64

fn Event::require_param_int64(self : Event, name : String) -> Int64 raise

Returns a route parameter parsed as an Int64, or raises HttpError(BadRequest) if missing or not a valid 64-bit integer.

#
Event::try_json

fn[T :
FromJson
] Event::try_json(self : Event) -> Result[T, String]

Tries to parse the request body as JSON, returning Ok(T) on success or Err(message) on failure. Shorthand for event.req.try_json().

#
HttpHandler

pub(all) struct HttpHandler(async (Event) -> &
Responder
noraise)

The async handler type for HTTP route callbacks.

#
Middleware

pub(all) struct Middleware(async (Event, MiddlewareNext) -> &
Responder
noraise)

A middleware function that receives a request event and a next continuation, and returns a response.

#
MiddlewareNext

pub(all) struct MiddlewareNext(async () -> &
Responder
noraise)

The continuation function passed to middleware, calling the next handler in the chain.

#
NativeServeOptions

pub struct NativeServeOptions {
max_connections : Int?
max_request_body_bytes : Int?
request_body_read_timeout_ms : Int?
handler_timeout_ms : Int?
shutdown_timeout_ms : Int?
websocket_outgoing_queue_capacity : Int?
websocket_overflow_policy :
NativeWebSocketOverflowPolicy
?
websocket_read_timeout_ms : Int?
websocket_max_message_bytes : Int?
}

Configuration options for the native async HTTP server runtime. The constructor validates all values eagerly and raises on invalid input.

#
NativeServeOptions::NativeServeOptions

fn NativeServeOptions::NativeServeOptions(max_connections? : Int, max_request_body_bytes? : Int, request_body_read_timeout_ms? : Int, handler_timeout_ms? : Int, shutdown_timeout_ms? : Int, websocket_outgoing_queue_capacity? : Int, websocket_overflow_policy? :
NativeWebSocketOverflowPolicy
, websocket_read_timeout_ms? : Int, websocket_max_message_bytes? : Int) -> NativeServeOptions raise

Validate and construct NativeServeOptions from optional tuning parameters. Each parameter defaults to None (moon fmt infers that from the ? markers) and is individually range-checked; raises if any value is out of range.

#
ResourceConfig

Configuration for registering a RESTful resource with standard CRUD routes.

#
ResourceConfig::ResourceConfig

Creates a new ResourceConfig with optional CRUD handlers.

#
StaticAssetMeta

pub(all) struct StaticAssetMeta {
asset_type : String?
etag : String?
mtime : Int64?
path : String?
size : Int64?
encoding : String?
}

Metadata describing a static asset including its content type, ETag, modification time, and size.

#
StaticAssetMeta::StaticAssetMeta

fn StaticAssetMeta::StaticAssetMeta(asset_type? : String, etag? : String, mtime? : Int64, path? : String, size? : Int64, encoding? : String) -> StaticAssetMeta

Creates a new StaticAssetMeta with optional fields for asset type, ETag, mtime, path, size, and encoding.

#
StaticResolvedResponder

type StaticResolvedResponder

#
html

Creates an HTML responder from any Show value, setting the Content-Type to text/html.

#
text

Creates a plain-text responder from any Show value, setting the Content-Type to text/plain.