A Gin-inspired web framework for MoonBit — fast, lightweight, idiomatic.
Dependencies
mbit is a MoonBit web framework, transplanted from Gin. It brings Gin's trie-based router, middleware chain, and context-centric API to MoonBit, reimagined for MoonBit's type system and async model.
mbit/
├── moon.pkg.json # root package — Mbit convenience wrapper, version
├── app.mbt # `Mbit` — chainable struct API over `core.Engine`
├── version.mbt
├── core/ # the engine core (mirrors Gin's single package)
│ ├── context.mbt # `Context`, `Handler`, `ResponseConn`, `TestConn`
│ ├── mbit.mbt # `Engine` (`new`/`default`), `h`, `url_encode`
│ ├── router.mbt # trie `Router`, `Node`, `RouteInfo`
│ ├── routergroup.mbt # `Group`
│ ├── binding.mbt # `bind_json` / `bind_query` / ... (tied to Context)
│ ├── validation.mbt # `Rule`, `FieldRules`, `validate_map`
│ ├── errors.mbt # `Method`, `status_text`
│ ├── response_writer.mbt, mode.mbt, utils.mbt, path.mbt, debug.mbt,
│ ├── logger.mbt # core middleware used by `default()`
│ └── recovery.mbt
├── middleware/ # optional middleware (depends on `core`)
│ ├── cors.mbt, gzip.mbt, auth.mbt, ratelimit.mbt, secure.mbt, requestid.mbt
├── template/ # standalone HTML template store (imported by `core`)
│ └── template.mbt
├── fs/ # standalone static file serving utilities
│ └── fs.mbt
├── demo/ # full-feature runnable demo
└── example/ # (incomplete, not a package yet)Note on the core package: binding/validation/logger/recovery live in core because MoonBit forbids defining methods on foreign types — the Context::should_bind_* / must_bind_* family must live alongside Context itself, so they ship with the engine (exactly as Gin keeps Default, Logger and Recovery in one package).
| Gin (Go) | mbit (MoonBit) |
|---|---|
| gin.Default() | default() |
| gin.New() | new() |
| gin.Context | Context |
| gin.HandlerFunc | Handler = async (Context) -> Unit |
| gin.H | h([]) helper |
| gin.RouterGroup | Group |
| engine.GET("/", handler) | app.get("/", [handler]) |
| engine.POST("/", handler) | app.post("/", [handler]) |
| engine.Any("/", handler) | app.any("/", [handler]) |
| engine.Handle("GET,POST", "/", h) | app.handle([GET, POST], "/", [h]) |
| engine.Group("/api") | app.group("/api") |
| engine.Use(mw) | app.use(mw) |
| engine.NoRoute(h) | app.no_route([h]) |
| engine.NoMethod(h) | app.no_method([h]) |
| engine.StaticFile("/f", "./f") | app.static_file("/f", "./f") |
| engine.Static("/s", "./d") | app.static_("/s", "./d") |
| c.Param("id") | ctx.param("id") |
| c.Query("q") | ctx.query("q") |
| c.PostForm("f") | ctx.post_form("f") |
| c.ShouldBindJSON(&obj) | bind_json(ctx) |
| c.ShouldBindQuery(&obj) | bind_query(ctx) |
| c.MustBindWith(&obj, binding.JSON) | ctx.must_bind_json() |
| c.JSON(200, gin.H{"k":"v"}) | ctx.json(200, Json::object(h([("k",Json::string("v"))]))) |
| c.XML(200, obj) | ctx.xml(200, data) |
| c.YAML(200, obj) | ctx.yaml(200, data) |
| c.HTML(200, tpl, data) | ctx.html(200, html) |
| c.String(200, "msg") | ctx.string(200, "msg") |
| c.Redirect(301, "/") | ctx.redirect(301, "/") |
| c.SSEvent("ev", "data") | ctx.sse("ev", "data") |
| c.Stream(...) | ctx.stream(200, "text/plain", writer) |
| c.Data(200, ct, data) | ctx.data(200, ct, data) |
| c.File("path") | ctx.file("path") |
| c.Set("key", val) | ctx.set("key", Json::string(val)) |
| c.Get("key") | ctx.get("key") |
| c.AbortWithStatus(403) | ctx.abort_with_status(403, "msg") |
| c.Next() | ctx.next() |
| c.ClientIP() | ctx.client_ip() |
| engine.Run(":8080") | app.run("0.0.0.0:8080") |
| gin.Recovery() | recovery() |
| gin.Logger() | logger() |
async fn main {
let app = @mbit.default()
app.get("/", [fn(ctx) { ctx.string(200, "Hello, mbit!") }])
app.get("/hello/:name", [fn(ctx) {
let name = ctx.param_default("name", "world")
ctx.string(200, "Hello, " + name + "!")
}])
app.run("0.0.0.0:8080")
}import {
"RabitLogic/mbit",
}app.get("/users", [handler])
app.post("/users", [handler])
app.put("/users/:id", [handler])
app.delete("/users/:id", [handler])
app.patch("/users/:id", [handler])
app.head("/health", [handler])
app.options("/api", [handler])
// Match all HTTP methods
app.any("/health", [handler])
// Register with explicit methods
app.handle([GET, POST], "/multi", [handler])app.get("/users/:id", [fn(ctx) {
let id = ctx.param("id") // Some("42")
let id_int = ctx.param_int("id") // Some(42)
let id_str = ctx.param_default("id", "0")
let all = ctx.params_all() // Map of all params
}])app.get("/files/*filepath", [fn(ctx) {
let path = ctx.param_default("filepath", "")
ctx.string(200, "Requested: " + path)
}])let api = app.group("/api")
api.get("/status", [status_handler])
api.post("/login", [login_handler])
// Nested groups
let admin = api.group("/admin")
admin.use(auth_middleware)
admin.get("/dashboard", [dashboard_handler])app.no_route([fn(ctx) {
ctx.json(404, Json::object({"error": Json::string("Not Found")}))
}])
app.no_method([fn(ctx) {
ctx.json(405, Json::object({"error": Json::string("Method Not Allowed")}))
}])app.redirect_trailing_slash(true) // /foo/ → /foo
app.redirect_fixed_path(true) // case-insensitive fix
app.remove_extra_slash(true) // //foo → /foo
app.handle_method_not_allowed(false) // disable 405| Middleware | Description |
|---|---|
| logger() | Logs method, path, status, latency (via structured Logger) |
| recovery() | Catches panics, returns 500 |
| cors(config) | CORS headers |
| gzip(config) | Response compression |
| request_id() | X-Request-Id header |
| timing() | X-Response-Time header |
| secure(config) | Security headers (helmet-like), CSRF option |
| auth() | Basic auth |
| rate_limit(config) | Rate limiting — fixed window / sliding window / token bucket, per-route & per-key |
| body_size_limit(bytes) | Reject large payloads |
| structured_logger(config) | JSON structured request log (fields, levels) |
| metrics_middleware(collector) | In-memory request metrics collector |
// Fixed window (simple, default)
app.use(rate_limit(RateLimitConfig::default()))
// Token bucket (smooth rate with burst)
app.use(rate_limit(RateLimitConfig::token_bucket(
RateTokenBucket::{ rate: 10.0, burst: 20 },
)))
// Per-route or per-user keying
app.use(rate_limit(
RateLimitConfig::default().with_key(fn(ctx) { ctx.client_ip() }),
))// Global middleware (applies to all routes)
app.use(logger())
app.use(recovery())
app.use(cors(CORSConfig::default()))
// Group middleware (applies to routes in that group)
let api = app.group("/api")
api.use(auth())
api.use(rate_limit(RateLimitConfig::{ max_requests: 100, window_secs: 60 }))app.use(logger())
// Output: 127.0.0.1 [GET] /api/users -> 200 (12ms)app.use(cors(CORSConfig::{
allow_origins: ["https://example.com"],
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Authorization"],
allow_credentials: true,
max_age: 3600,
expose_headers: ["Content-Length"],
}))app.use(gzip(GzipConfig::{ min_length: 512, level: 6 }))app.use(rate_limit(RateLimitConfig::{
max_requests: 50,
window_secs: 60,
}))app.use(secure(SecureConfig::default()))
// Sets: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection,
// Content-Security-Policy, Referrer-Policyfn auth_middleware() -> Handler {
async fn(ctx) {
match ctx.header("Authorization") {
Some(token) => {
ctx.set("token", Json::string(token)) // store for downstream handlers
ctx.next() // continue the chain
}
None => ctx.abort_with_status(401, "Unauthorized")
}
}
}
app.use(auth_middleware())let cfg = Config::from_env()
let port = cfg.get_int("PORT", default=8080)
let db_url = cfg.get_string("DATABASE_URL", default="")
cfg.environment() // AppEnvironment::Development / Staging / Production
// Strongly-typed server config
let srv = ServerConfig::from_env()
app.set_max_multipart_memory(srv.max_upload_size)Logger::set_level(Info)
Logger::set_format(JSONFormat)
Logger::set_app_name("my-service")
Logger::info("User logged in", fields=[("user_id", "42")])
// Per-request JSON logging middleware
app.use(structured_logger(StructuredLogConfig::default()))
// In-memory metrics collector
let m = MetricsCollector::new()
app.use(metrics_middleware(m))app.get("/ws", [websocket_handler(fn(ws) {
ws.send_text("Connected!")
match ws.read_message() {
Some(msg) => ws.send_text("Echo: " + msg.to_string())
None => ()
}
})])app.health() // registers GET /health -> 200 {"status":"ok","service":"mbit"}ctx.http_method() // "GET", "POST", etc.
ctx.path() // "/users/42"
ctx.full_path() // "/users/42?page=1"
ctx.client_ip() // client IP address
ctx.content_type() // "application/json"
ctx.header("Accept") // optional header value
ctx.is_websocket() // true if upgrade requestctx.query("page") // Some("1")
ctx.query_default("page", "1") // "1"
ctx.query_int("page") // Some(1)
ctx.query_int64("id") // Some(42L)
ctx.query_map() // all query params as Map
ctx.query_array("tags") // ["a", "b"] for ?tags=a&tags=bctx.body_string() // raw body as string
ctx.body_json() // parsed as Json
// Form data (application/x-www-form-urlencoded)
ctx.post_form("name") // Some("Alice")
ctx.default_post_form("name", "unknown")
ctx.post_form_map() // all form fields// JSON
ctx.json(200, Json::object({"message": Json::string("ok")}))
ctx.json_indented(200, data)
ctx.json_secure(200, data) // with while(1) prefix
ctx.json_pure(200, data) // no escaping
// XML & YAML
ctx.xml(200, "root", data)
ctx.yaml(200, data)
// HTML & Text
ctx.html(200, "<h1>Hello</h1>")
ctx.string(200, "plain text")
// Redirect
ctx.redirect(301, "/new-path")
// File
ctx.file("path/to/file.pdf")
// SSE (Server-Sent Events)
ctx.sse("event: message\ndata: hello\n\n")
// Streaming
ctx.stream(200, fn(writer) {
writer("chunk1")
writer("chunk2")
})ctx.set("user_id", Json::string("42"))
let user_id = ctx.get("user_id") // Some(Json::String("42"))
ctx.get_string("user_id") // Some("42")ctx.set_header("X-Custom", "value")
ctx.status_code // current status (default 200)
ctx.is_written() // has response been sentstruct LoginRequest {
username : String
password : String
} derive(@json.FromJson)
// JSON body
app.post("/login", [async fn(ctx) {
let req : LoginRequest? = bind_json(ctx)
match req {
Some(r) => ctx.json(200, Json::object({"user": Json::string(r.username)}))
None => ctx.abort_with_status(400, "Invalid JSON")
}
}])
// Query string: GET /search?q=hello&page=1
struct SearchQuery {
q : String
page : String
} derive(@json.FromJson)
let query = bind_query(ctx)
// Form body
let form = bind_form(ctx)
// Headers
let headers = bind_header(ctx)
// Path params
let params = bind_path(ctx)let req = ctx.must_bind_json::[LoginRequest]() // aborts with 400 on failurelet rules = [
FieldRules::new("username", [Required, Min(3), Max(20)]),
FieldRules::new("email", [Required, Email]),
FieldRules::new("age", [Min(0), Max(150)]),
]
let errors = validate_map(values, rules)
if errors.length() > 0 {
ctx.abort_with_status(422, "Validation failed")
return
}| Rule | Description |
|---|---|
| Required | Must not be empty |
| Min(n) | Minimum length or numeric value |
| Max(n) | Maximum length or numeric value |
| Len(n) | Exact length |
| Pattern(regex) | Must match pattern |
| OneOf([...]) | Must be one of the values |
| Basic email check | |
| URL | Basic URL check |
| Custom(fn) | Custom validation function |
app.set_html_template("welcome", "<html><body>Hello {{.Name}}!</body></html>")
app.set_template_delims("{{", "}}")
// In handler
ctx.html_template("welcome", {"Name": "World"})// Single file
app.static_file("/favicon.ico", "./public/favicon.ico")
// Directory
app.static_("/static", "./public")
// Directory with wildcard
app.static_fs("/assets", "./public")// Basic
app.run("0.0.0.0:8080")
// Default (logs port)
app.run_default()
// Graceful shutdown
app.run_with_shutdown(":8080", on_shutdown=fn() {
println("Cleaning up...")
})
// Trigger shutdown from signal handler
app.shutdown()2026-08-02 12:34:56.789 UTC engine created with logger + recovery middleware
2026-08-02 12:34:56.790 UTC route GET /
2026-08-02 12:34:56.790 UTC route GET /hello/:name
2026-08-02 12:34:56.791 UTC listening on http://0.0.0.0:8080let routes = app.routes()
for r in routes {
println(r.meth + " " + r.path)
}
println(app.trees()) // ASCII tree of the routerlet engine = Engine::new()
engine.max_multipart_memory(32 * 1024 * 1024) // 32 MB
engine.redirect_trailing_slash(true)
engine.handle_method_not_allowed(true)
engine.remove_extra_slash(true)
engine.redirect_fixed_path(false)
let app = Mbit::new(engine)async fn main {
let app = @mbit.default()
// Global middleware
app.use(logger())
app.use(recovery())
app.use(cors(CORSConfig::default()))
app.use(request_id())
app.use(timing())
// Custom 404
app.no_route([fn(ctx) {
ctx.json(404, Json::object({"error": Json::string("Not Found")}))
}])
// Routes
app.get("/", [fn(ctx) { ctx.string(200, "Hello, mbit!") }])
app.get("/hello/:name", [fn(ctx) {
ctx.string(200, "Hello, " + ctx.param_default("name", "world") + "!")
}])
app.post("/api/echo", [async fn(ctx) {
match ctx.body_json() {
Some(json) => ctx.json(200, json)
None => ctx.abort_with_status(400, "Invalid JSON")
}
}])
// Protected group
let api = app.group("/api")
api.use(fn(ctx) {
match ctx.header("Authorization") {
Some(_) => ctx.next()
None => ctx.abort_with_status(401, "Unauthorized")
}
})
api.get("/users", [list_users])
// Static files
app.static_("/static", "./public")
app.run("0.0.0.0:8080")
}| Method | Description |
|---|---|
| Mbit::default() | Create with logger + recovery |
| Mbit::new(engine) | Create with custom engine |
| get/post/put/delete/patch/head/options(path, handlers) | Route registration |
| any(path, handlers) | Match all methods |
| handle(methods, path, handlers) | Match specific methods |
| use(mw) | Add global middleware |
| group(prefix) | Create route group |
| no_route(handlers) | 404 handler |
| no_method(handlers) | 405 handler |
| static_file(path, file) | Serve single file |
| static_(prefix, root) | Serve directory |
| static_fs(prefix, root) | Serve directory with wildcard |
| set_html_template(name, content) | Register template |
| max_multipart_memory(bytes) | Set upload limit |
| redirect_trailing_slash(bool) | Auto-redirect trailing slashes |
| handle_method_not_allowed(bool) | Enable 405 responses |
| remove_extra_slash(bool) | Normalize double slashes |
| run(addr) | Start server |
| run_default() | Start on default port |
| run_with_shutdown(addr, on_shutdown) | Start with graceful shutdown |
| shutdown() | Trigger shutdown |
| routes() | List all routes |
| trees() | Print route tree |
| Method | Returns | Description |
|---|---|---|
| http_method() | String | HTTP method |
| path() | String | Request path |
| full_path() | String | Path with query string |
| param(key) | String? | Path parameter |
| param_default(key, default) | String | Path parameter with fallback |
| param_int(key) | Int? | Path parameter as Int |
| param_int64(key) | Int64? | Path parameter as Int64 |
| params_all() | Map[String,String] | All path params |
| query(key) | String? | Query parameter |
| query_default(key, default) | String | Query with fallback |
| query_int(key) | Int? | Query as Int |
| query_int64(key) | Int64? | Query as Int64 |
| query_map() | Map[String,String] | All query params |
| query_array(key) | Array[String] | Multi-value query |
| header(key) | String? | Request header |
| client_ip() | String | Client IP |
| content_type() | String | Content-Type |
| body_string() | String | Raw body |
| body_json() | Json? | Parsed JSON body |
| post_form(key) | String? | Form field |
| json(code, data) | Unit | Render JSON |
| xml(code, root, data) | Unit | Render XML |
| yaml(code, data) | Unit | Render YAML |
| html(code, html) | Unit | Render HTML |
| string(code, text) | Unit | Render plain text |
| data(code, contentType, body) | Unit | Render custom |
| redirect(code, url) | Unit | Redirect |
| file(path) | Unit | Serve file |
| sse(event) | Unit | SSE event |
| stream(code, writerFn) | Unit | Stream response |
| set_header(key, value) | Unit | Set response header |
| set(key, value) | Unit | Store value |
| get(key) | Json? | Get stored value |
| get_string(key) | String? | Get stored string |
| abort() | Unit | Stop handler chain |
| abort_with_status(code, msg) | Unit | Abort with JSON error |
| is_aborted() | Bool | Check if aborted |
| next() | Unit | Continue to next handler |
| must_bind_json::[T]() | T | Bind JSON or abort |
| negotiate_format(offers) | String? | Content negotiation |
| is_websocket() | Bool | WebSocket upgrade check |
fn name_and_version() -> StringA Gin-inspired web framework for MoonBit — fast, lightweight, idiomatic.
Dependencies