mbit

A Gin-inspired web framework for MoonBit — fast, lightweight, idiomatic.

web
http
framework
router
moon add RabitLogic/mbit@0.3.0
Download zip
Version
0.3.0
License
Apache-2.0
Last updated
16 days ago
Downloads
17
README

#mbit

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.

A MoonBit web framework.

MoonBit Tests Gin

#Project Structure

mbit is split into focused sub-packages. Everything is re-exported for your convenience through the root mbit package (Mbit wrapper), while the framework internals live in well-separated packages:

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).

#Features

  • Trie-based router:param and *wildcard matching, route groups, custom 404/405
  • Middleware — logger, recovery, CORS, gzip, auth, rate-limit (fixed window / sliding window / token bucket, per-route & per-key), secure headers, request ID, timing
  • Rendering — JSON, XML, YAML, HTML, SSE, streaming, file serving, content negotiation
  • Binding — JSON, query, form, header, path parameter binding with FromJson
  • Validation — declarative field validation (required, min, max, email, URL, custom)
  • Template — HTML template rendering with variable substitution
  • Static files — serve single files or entire directories
  • Structured loggingLogger with levels/formats/fields (powered by moonbit-log), structured_logger() middleware, metrics_middleware() collector
  • Configuration — multi-environment config, env-var binding, validation, hot-reload hooks (Config, ServerConfig, AppEnvironment)
  • WebSocket — upgrade detection, frame encode/decode, and message handling (websocket_handler(fn(ws) { ... }))
  • Health endpoint — built-in /health probe (app.health()) for load balancers / containers

#Relationship to Gin

mbit transplants Gin v1.x's architecture and API design into MoonBit. The core concepts map directly, adapted to MoonBit's type system and async model.

Gin (Go)mbit (MoonBit)
gin.Default()default()
gin.New()new()
gin.ContextContext
gin.HandlerFuncHandler = async (Context) -> Unit
gin.Hh([]) helper
gin.RouterGroupGroup
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()

#Key differences

  • Async handlers: async (Context) -> Unit instead of synchronous func(*gin.Context)
  • Generics over reflection: Binding uses FromJson trait, not reflect
  • Explicit validation: FieldRules replace struct tags like binding:"required"
  • Immutable by default: mut keyword required; Map([]) for empty maps
  • Engine is chaining: Route registration returns Engine, chainable with |> ignore

#Quick Start

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")
}

#Installation

Add to moon.pkg:

import {
"RabitLogic/mbit",
}

#Routing

#Basic Routes

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])

#Path Parameters

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
}])

#Wildcard Routes

app.get("/files/*filepath", [fn(ctx) {
let path = ctx.param_default("filepath", "")
ctx.string(200, "Requested: " + path)
}])

#Route Groups

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])

#Custom Error Handlers

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")}))
}])

#Redirects & Path Options

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

#Built-in Middleware

MiddlewareDescription
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

#Rate limiting strategies

// 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() }),
))

#Using Middleware

// 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 }))

#Logger

app.use(logger())
// Output: 127.0.0.1 [GET] /api/users -> 200 (12ms)

#CORS

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"],
}))

#Gzip

app.use(gzip(GzipConfig::{ min_length: 512, level: 6 }))

#Rate Limiting

app.use(rate_limit(RateLimitConfig::{
max_requests: 50,
window_secs: 60,
}))

#Secure Headers

app.use(secure(SecureConfig::default()))
// Sets: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection,
// Content-Security-Policy, Referrer-Policy

#Custom Middleware

fn 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())

#Configuration

Multi-environment config with env-var binding (APP_ prefix by default):

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)

#Structured Logging

Powered by moonbit-log, with levels, formats and fields:

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))

#WebSocket

Upgrade detection, frame encode/decode and message handling:

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 => ()
}
})])

#Health Endpoint

app.health() // registers GET /health -> 200 {"status":"ok","service":"mbit"}

#Context

The Context carries everything about the current request.

#Request Info

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 request

#Query Parameters

ctx.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=b

#Request Body

ctx.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

#Response Rendering

// 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")
})

#Context Store (Key-Value)

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")

#Response Headers

ctx.set_header("X-Custom", "value")
ctx.status_code // current status (default 200)
ctx.is_written() // has response been sent

#Binding

Bind request data to structs using FromJson.

struct 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)

#MustBind (aborts on failure)

let req = ctx.must_bind_json::[LoginRequest]() // aborts with 400 on failure

#Validation

let 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
}

#Available Rules

RuleDescription
RequiredMust 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
EmailBasic email check
URLBasic URL check
Custom(fn)Custom validation function

#Templates

app.set_html_template("welcome", "<html><body>Hello {{.Name}}!</body></html>")
app.set_template_delims("{{", "}}")

// In handler
ctx.html_template("welcome", {"Name": "World"})

#Static Files

// Single file
app.static_file("/favicon.ico", "./public/favicon.ico")

// Directory
app.static_("/static", "./public")

// Directory with wildcard
app.static_fs("/assets", "./public")

#Server

// 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()

#Debug

In debug mode (set_mode(Debug) / mbit.default()), the framework prints startup and route-registration diagnostics with a full UTC timestamp YYYY-MM-DD HH:MM:SS.mmm UTC — no [mbit] prefix:

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:8080

Programmatic route introspection is also available:

let routes = app.routes()
for r in routes {
println(r.meth + " " + r.path)
}

println(app.trees()) // ASCII tree of the router

#Configuration

let 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)

#Complete Example

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")
}

#API Reference

#Mbit

MethodDescription
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

#Context

MethodReturnsDescription
http_method()StringHTTP method
path()StringRequest path
full_path()StringPath with query string
param(key)String?Path parameter
param_default(key, default)StringPath 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)StringQuery 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()StringClient IP
content_type()StringContent-Type
body_string()StringRaw body
body_json()Json?Parsed JSON body
post_form(key)String?Form field
json(code, data)UnitRender JSON
xml(code, root, data)UnitRender XML
yaml(code, data)UnitRender YAML
html(code, html)UnitRender HTML
string(code, text)UnitRender plain text
data(code, contentType, body)UnitRender custom
redirect(code, url)UnitRedirect
file(path)UnitServe file
sse(event)UnitSSE event
stream(code, writerFn)UnitStream response
set_header(key, value)UnitSet response header
set(key, value)UnitStore value
get(key)Json?Get stored value
get_string(key)String?Get stored string
abort()UnitStop handler chain
abort_with_status(code, msg)UnitAbort with JSON error
is_aborted()BoolCheck if aborted
next()UnitContinue to next handler
must_bind_json::[T]()TBind JSON or abort
negotiate_format(offers)String?Content negotiation
is_websocket()BoolWebSocket upgrade check

#License

MIT

#
Mbit

pub struct Mbit {
engine :
Engine

}

Mbit wraps an Engine providing method-based access.

#
Mbit::any

fn Mbit::any(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::default

fn Mbit::default() -> Mbit

Create an Mbit with default settings (logger + recovery middleware).

#
Mbit::delete

fn Mbit::delete(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::engine

Expose the underlying Engine for advanced usage.

#
Mbit::get

fn Mbit::get(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit
——————————————————————————————————————————————————————————————————————

#
Mbit::group

fn Mbit::group(self : Mbit, prefix : String) ->
Group
——————————————————————————————————————————————————————————————————————

#
Mbit::handle

fn Mbit::handle(self : Mbit, methods : Array[
Method
], pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::handle_method_not_allowed

fn Mbit::handle_method_not_allowed(self : Mbit, enable : Bool) -> Unit

#
Mbit::head

fn Mbit::head(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::health

fn Mbit::health(self : Mbit) -> Unit

Register a built-in /health endpoint (load balancer / probe checks).

#
Mbit::max_multipart_memory

fn Mbit::max_multipart_memory(self : Mbit, bytes : Int64) -> Unit
——————————————————————————————————————————————————————————————————————

#
Mbit::new

Create an Mbit with a custom engine.

#
Mbit::no_method

fn Mbit::no_method(self : Mbit, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::no_route

fn Mbit::no_route(self : Mbit, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::options

fn Mbit::options(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::patch

fn Mbit::patch(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::post

fn Mbit::post(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::put

fn Mbit::put(self : Mbit, pattern : String, handlers : Array[async (
Context
) -> Unit]) -> Unit

#
Mbit::redirect_fixed_path

fn Mbit::redirect_fixed_path(self : Mbit, enable : Bool) -> Unit

#
Mbit::redirect_trailing_slash

fn Mbit::redirect_trailing_slash(self : Mbit, enable : Bool) -> Unit

#
Mbit::remove_extra_slash

fn Mbit::remove_extra_slash(self : Mbit, enable : Bool) -> Unit

#
Mbit::routes

——————————————————————————————————————————————————————————————————————

#
Mbit::run

async fn Mbit::run(self : Mbit, addr : String) -> Unit
——————————————————————————————————————————————————————————————————————

#
Mbit::run_default

async fn Mbit::run_default(self : Mbit) -> Unit

#
Mbit::run_with_shutdown

async fn Mbit::run_with_shutdown(self : Mbit, addr : String, on_shutdown? : () -> Unit) -> Unit

#
Mbit::set_func_map

fn Mbit::set_func_map(self : Mbit, funcs : Map[String, String]) -> Unit

#
Mbit::set_html_template

fn Mbit::set_html_template(self : Mbit, name : String, content : String) -> Unit
——————————————————————————————————————————————————————————————————————

#
Mbit::set_template_delims

fn Mbit::set_template_delims(self : Mbit, left : String, right : String) -> Unit

#
Mbit::shutdown

fn Mbit::shutdown(self : Mbit, cleanup? : () -> Unit) -> Bool

#
Mbit::static_

fn Mbit::static_(self : Mbit, relative_path : String, root : String) -> Unit

#
Mbit::static_file

fn Mbit::static_file(self : Mbit, relative_path : String, file_path : String) -> Unit
——————————————————————————————————————————————————————————————————————

#
Mbit::static_fs

fn Mbit::static_fs(self : Mbit, relative_path : String, root : String) -> Unit

#
Mbit::trees

fn Mbit::trees(self : Mbit) -> String

#
Mbit::use

fn Mbit::use(self : Mbit, mw : async (
Context
) -> Unit) -> Unit
——————————————————————————————————————————————————————————————————————

#
name_and_version

fn name_and_version() -> String

Framework name and version for User-Agent headers.

#
version

fn version() -> String

Framework version string.

Source Files