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 usemoonbitlang/asyncon both targets. JS and wasm-gc are not supported. Native remains the preferred target inmoon.mod.
moon add bobzhang/crescent///|
async fn main {
let app = @crescent.App()
app.get("/", _ => "Hello, Crescent!")
app.serve(port=4000)
}moon run . --target native
# Visit http://localhost:4000This walkthrough builds a complete REST API step by step.
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}"
),
)
}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
}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(_))
}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)
}///|
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"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)
}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| Pattern | Example URL | Captures |
|---|---|---|
/users |
/users |
(exact match) |
/users/:id |
/users/42 |
event.param("id") = "42" |
/users/:id/posts/:pid |
/users/1/posts/99 |
two params |
/files/* |
/files/readme.txt |
one segment in _ |
/static/** |
/static/css/main.css |
any 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)
} 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).
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, ...).
///|
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).
| Method | Purpose |
|---|---|
get_meta |
Resolve the path to asset metadata (size, mtime, ETag); None means "not found" |
get_contents |
Produce the response body for the resolved asset |
get_type |
Return the Content-Type for the path (None skips the header) |
get_encodings |
Provider-wide Content-Encoding → variant suffix map (e.g. gzip → .gz) |
get_index_names |
Filenames to try when the request points at a directory |
get_fallthrough |
If true, a miss falls through to the next route instead of 404 |
// 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(_))
}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.
// 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,
)) 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)
)) 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
)) let shutdown = @async.Queue()
// In another task: shutdown.put(()) to stop the server
app.serve(port=4000, shutdown~) let addr = @socket.Addr::parse("0.0.0.0:4000")
let server = @http.Server(addr, reuse_addr=true)
app.serve_on(server) 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)
}All get/post/put/patch/delete handlers catch errors automatically:
| What you write | What 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 input |
400 with parse error message |
event.require_param_int("id") on "abc" |
400 "must be a valid integer" |
| Any unhandled error | 500 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}")
}
})Middleware implementations live in the bobzhang/crescent/middleware sub-package.
| Middleware | What 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()
}
}| Method | Returns | On missing/invalid |
|---|---|---|
event.param("name") |
String? |
None |
event.param_int("id") |
Int? |
None |
event.param_int64("id") |
Int64? |
None |
event.require_param("name") |
String |
raises 400 |
event.require_param_int("id") |
Int |
raises 400 |
event.require_param_int64("id") |
Int64 |
raises 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"))
}| Method | Returns | Notes |
|---|---|---|
event.json[T]() |
T |
Raises on invalid JSON |
event.try_json[T]() |
Result[T, String] |
For custom error handling |
event.req.body[T]() |
T |
Via 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() |
String |
Path 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)
}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")
}- 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 output —
BytesandHttpResponseskip the intermediate buffer
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
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.
Apache-2.0