crescent

Crescent: A web framework for MoonBit. Fork of hnlyxiaobing/crescent with Debug trait fix for wbtest.

http
server
web
framework
async
ai-friendly
Download zip
Version
0.10.7
License
Apache-2.0
Last updated
21 hours ago
Downloads
152

Dependencies

#Crescent (hnlyxiaobing fork)

This is a fork of hnlyxiaobing/crescent at v0.10.0.

Why this fork exists: The upstream hnlyxiaobing/crescent@0.10.0 has a white-box test (core/request.mbt, test "try_json returns Ok on valid json") that fails to compile under current MoonBit trait rules. The test uses assert_eq with an anonymous struct literal Ok({ name: "Alice", age: 30 }), but TryJsonTestUser does not derive Debug, which assert_eq requires to format failure messages.

Fix applied: Added Debug to derive(...) on TryJsonTestUser and bound the expected value to a local variable before calling assert_eq.

Plan: We will submit this fix as a PR to upstream. Once merged and released, this fork will be deprecated in favor of hnlyxiaobing/crescent.

Original README below (unchanged except for this notice):


#Crescent

A web framework for MoonBit. Native-first, type-safe, AI-agent friendly.

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

Target: native only. Crescent's HTTP/WebSocket runtime depends on moonbitlang/async/{http,socket,websocket}, which are not available on the wasm or js targets. Use moon build/moon test (which default to the preferred-target: "native" declared in moon.mod.json) or pass --target native explicitly.

#Install

moon add hnlyxiaobing/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: ...,
#|}
),
)

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 hnlyxiaobing/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: {},
}
let result = Ok(event.require_param_int("id")) catch { e => Err(e) }
assert_true(result is Err(_))
}

#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

hnlyxiaobing/crescent — Core: routing, middleware, serving, WebSocket hnlyxiaobing/crescent/httputil — HTTP protocol: headers, dates, URL encoding hnlyxiaobing/crescent/cors — CORS middleware hnlyxiaobing/crescent/fetch — HTTP client hnlyxiaobing/crescent/static_file — Static file provider (filesystem) hnlyxiaobing/crescent/test_client — In-process test client (no network I/O) hnlyxiaobing/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.

IOError::to_repr

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.

NativeServeError::to_json

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

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 hnlyxiaobing/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

StaticResolvedResponder::options

StaticResolvedResponder::output

StaticResolvedResponder::output_bytes

fn StaticResolvedResponder::output_bytes(self : StaticResolvedResponder) -> Bytes?

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.