Sign in

    onho

    A Hono-style lightweight web framework for MoonBit

    http
    web
    routing
    wasi
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    5

    #anccnuer/onho

    English | 中文

    MoonBit 写的 Web 框架,路由风格类似 Hono,编译成 wasm 后以 WCGI 方式跑在 Wasmer Edge 上。

    #快速上手

    moon add anccnuer/onho

    let app = @onho.App::new()

    app.get("/hello/:name", req => @onho.response(200, "hello \{req.param("name")}"))
    app.get("/user/:id", req => @onho.response_json(200, "{\"id\":\"\\{req.param("id")}\"}"))
    app.get("/static/*", req => @onho.response(200, req.param("*")))
    app.post("/echo", req => @onho.response(200, req.body))
    app.all("/ping", _req => @onho.response(200, "pong")) // 任意方法

    app.get("/redirect", _req => @onho.redirect("/hello/world", status=301))
    app.get("/page", _req => @onho.html(200, "<h1>hi</h1>"))

    读请求:

    req.header("x-request-id") // 头名一律小写;没有就返回 ""
    req.query_param("q") // 查询参数,已做百分号解码(%E4%B8%AD -> 中,+ -> 空格)
    req.param("id") // 路由参数,通配符的捕获值放在 "*"
    req.body // 原始 body 文本

    构造响应:

    @onho.response(204, "") // text/plain; charset=utf-8
    @onho.response_json(200, "{\"ok\":true}") // application/json
    @onho.html(200, "<p>hi</p>") // text/html; charset=utf-8
    @onho.redirect("/login", status=302) // location 头 + 空 body,默认 302
    @onho.response_with(200, [("x-a", "1")], "已设置") // 完全控制
    @onho.response(200, "ok").set_header("x-b", "2") // 追加一个头

    中间件(先注册的在外层;path~ 不传就对所有路径生效):

    app.use_middleware((req, next) => {
    // 进来时做的事,比如鉴权
    let res = next(req)
    res.set_header("x-response-by", "onho") // 出去时也可以改响应
    }, path="/api/*")

    app.use_middleware((_req, _next) => @onho.response(401, "denied")) // 不调 next 即短路

    handler 里可以 raise,不用层层 try:

    app.get("/api/user/:id", req => {
    let id = @string.parse_int(req.param("id")) catch { _ => raise Failure("bad id") }
    @onho.response_json(200, "{\"id\":\{id}}")
    })

    app.on_error((_req, _err) => @onho.response(500, "internal error")) // 不注册则默认 500
    app.not_found(_req => @onho.response_json(404, "{\"error\":\"not found\"}"))

    表单与多值参数:

    req.query_all("tag") // ?tag=a&tag=b -> ["a", "b"]
    req.form_param("name") // form body 字段;content-type 不是表单时返回 ""
    req.form_all("t") // 表单字段的全部值

    内置日志中间件,一行请求摘要写到 stderr(wasm 里也走 fd 2,不污染响应):

    app.use_middleware(@onho.logger()) // GET /health -> 200 OK

    接到真实的请求,取决于你跑在什么宿主后面:

    // WCGI / CGI:请求在环境变量里,body 在 stdin
    let res = match @onho.request_from_cgi(env, body) {
    Some(req) => app.handle(req)
    None => @onho.response(400, "bad request")
    }

    // 宿主直接递原始 HTTP 字节
    @onho.parse_request(bytes) // Request?,解析失败是 None

    #跑通示例项目

    仓库里的 cmd/wcgi 是一个能直接部署的例子:

    bash dev/check.sh # moon test + wasm 构建 + fixture 比对 + wasmer 冒烟 node dev/host.mjs serve 8080 # 本地起一个真实的 HTTP 服务 curl -s localhost:8080/hello/world

    #部署

    moon build --target wasm --release wasmer deploy # 读 app.yaml 和 wasmer.toml

    部署后若所有请求都 400,查 runner 是否给了 SCRIPT_NAME/PATH_INFO;若所有路径返回 同一页面,是 runner 固定了 SCRIPT_NAME,此时路由在 PATH_INFO 上。

    #目录结构

    • http.mbt — Request/Response、CGI 与原始 HTTP 边界
    • app.mbt — App 路由器:中间件、405/404、HEAD 回退、错误处理
    • cmd/wcgi/ — 可部署的示例程序
    • test/、fixtures/、dev/ — 测试与本地 WCGI 宿主

    Handler

    type Handler = (Request) -> Response raise

    A route handler. It may raise an Error, which the router turns into a 500 via the app's on_error handler, so one bad parse does not take the response down with it. Handlers that never raise are accepted unchanged.

    Middleware

    type Middleware = (Request, (Request) -> Response raise) -> Response raise

    A middleware wraps the chain. Registration order is wrapping order: the first used middleware is outermost. A middleware may short-circuit by returning without calling next, or catch what the chain below it raised.
    type Next = (Request) -> Response raise

    App

    type App

    The application object. Matching walks each registered pattern segment by segment; among competing matches the most specific route wins, with registration order breaking ties.

    App::all

    fn App::all(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    Registers handler for path under every method, recorded as ANY.

    App::delete

    fn App::delete(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    App::get

    fn App::get(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    App::handle

    fn App::handle(self : App, req : Request) -> Response

    Resolves a request. Routes are tested by pattern; among routes whose patterns match the path, the most specific route whose method matches (or ANY) wins. A path that only exists under different methods yields 405 with an allow header listing them, so "no route" stays distinguishable from "wrong route".

    Registered middleware wraps the outcome, a HEAD request falls back to the GET handler for its path with the body dropped, and an Error raised out of the chain becomes a 500 through the app's on_error handler.

    App::new

    fn App::new() -> App

    App::not_found

    fn App::not_found(self : App, handler : (Request) -> Response raise) -> Unit

    Replaces the default 404 for requests that match no route.

    App::on_error

    fn App::on_error(self : App, handler : (Request, Error) -> Response) -> Unit

    Replaces the default 500 for an Error raised out of a handler or middleware. The default answers plain-text 500 and leaks nothing.

    App::patch

    fn App::patch(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    App::post

    fn App::post(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    App::put

    fn App::put(self : App, path : String, handler : (Request) -> Response raise) -> Unit

    App::route

    fn App::route(self : App, http_method : String, path : String, handler : (Request) -> Response raise) -> Unit

    Registers handler for http_method + path. path may be a pattern with literal segments, :name parameters and at most one trailing * wildcard. ANY matches every method. Registering the exact same method and pattern twice prints a warning to stderr, because the second route can never be reached — the first one always wins.

    App::use_middleware

    fn App::use_middleware(self : App, middleware : (Request, (Request) -> Response raise) -> Response raise, path? : String) -> Unit

    Appends middleware to the chain, applied to requests whose path matches path (a pattern like a route's, defaulting to every path). The first registered middleware is outermost.

    Request

    pub struct Request {
    http_method : String
    path : String
    query : String
    headers : Array[(String, String)]
    body : String
    params : Array[(String, String)]
    }

    An HTTP request, built by the boundary from whatever the host handed over.

    path is the part routes are matched on and never contains a query string, query is the raw text after ?. Header names are lowercased and kept in arrival order, so repeated headers survive a round trip. params starts empty and is filled by the router when a :name or trailing * pattern matches the path.

    Request::form_all

    fn Request::form_all(self : Request, name : String) -> Array[String]

    Every value of a application/x-www-form-urlencoded body field, in arrival order.

    Request::form_param

    fn Request::form_param(self : Request, name : String) -> String

    A application/x-www-form-urlencoded body field, percent escapes and + signs decoded. Bodies of other content types read as empty, so a handler can call this unconditionally. The last value of a repeated field wins.

    Request::header

    fn Request::header(self : Request, name : String) -> String

    A request header by lowercase name; absent reads as empty.

    Request::param

    fn Request::param(self : Request, name : String) -> String

    A path parameter captured by the router, for example id in the route /user/:id. The value captured by a trailing * wildcard is stored under the name *. Absent names read as empty, and the last value wins.

    Request::query_all

    fn Request::query_all(self : Request, name : String) -> Array[String]

    Every value of a query parameter, in arrival order, each with its percent escapes and + signs decoded — so ?tag=a&tag=b reads back as both.

    Request::query_param

    fn Request::query_param(self : Request, name : String) -> String

    One value of a application/x-www-form-urlencoded query parameter, with its percent escapes and + signs decoded.

    Request::set_header

    fn Request::set_header(self : Request, name : String, value : String) -> Request

    Returns the request with one more header, for middleware that enriches the request before it reaches the route. Repeated names stay separate entries.

    Request::uri

    fn Request::uri(self : Request) -> String

    The request line as the client wrote it, so a redirect can send the whole URI back.

    Response

    pub struct Response {
    status : Int
    headers : Array[(String, String)]
    body : String
    }

    Fields are readable so handlers can inspect what came back, but building one goes through response/response_with.

    Response::header

    fn Response::header(self : Response, name : String) -> String

    A response header by lowercase name; absent reads as empty.

    Response::set_header

    fn Response::set_header(self : Response, name : String, value : String) -> Response

    Returns a copy of the response with one more header. Repeated names stay as separate entries, which is how set-cookie must be sent.

    html

    fn html(status : Int, body : String) -> Response

    Builds an HTML response.

    logger

    fn logger() -> ((Request, (Request) -> Response raise) -> Response raise)

    Builds a logging middleware. Each handled request produces one line on the standard error channel — <method> <path> -> <status> — which is where a CGI runner collects guest diagnostics, keeping stdout exclusively for the response itself.

    The line records the status as it arrived at this layer, so middleware further out that rewrites the response is not reflected here.

    parse_request

    fn parse_request(raw : Bytes) -> Request?

    Parses raw HTTP request bytes. Returns None for anything without a usable request line, or with invalid UTF-8, which the boundary turns into a 400.

    The body is whatever follows the blank line, taken whole: the host hands over a complete request, so framing does not need content-length.

    reason_phrase

    fn reason_phrase(status : Int) -> String

    Status reason phrase. Unknown codes fall back to the generic class text, so a serialized response stays valid HTTP.

    redirect

    fn redirect(to : String, status? : Int) -> Response

    Builds a redirect response with a location header and an empty body. Defaults to 302; pass status~ for 301, 307 or 308.

    request_from_cgi

    fn request_from_cgi(env : Array[(String, String)], body : String) -> Request?

    Builds a Request from the CGI environment and the body read off stdin.

    A request with no method, or with no variable holding a path, yields None, which the boundary turns into a 400. Which variable that is is not fixed by the spec: a gateway that splits the URI hands the app-relative tail in PATH_INFO and keeps its own name in SCRIPT_NAME, a runner that does not split leaves everything in SCRIPT_NAME, and a gateway that only forwards the request line puts it in the non-standard REQUEST_URI. Trying them in that order routes all three the same way. QUERY_STRING stays separate, so a route registered for /health also answers /health?debug=1. Client headers arrive as HTTP_*, and their values are trimmed because the environment keeps the optional whitespace RFC 3875 allows around the =.

    response

    fn response(status : Int, body : String) -> Response

    response_json

    fn response_json(status : Int, body : String) -> Response

    Builds a JSON response. RFC 8259 defines JSON as UTF-8-only, so the content type carries no charset parameter.

    response_with

    fn response_with(status : Int, headers : Array[(String, String)], body : String) -> Response

    Builds a response with an exact header list, for handlers that need to set location, set-cookie or their own content-length.

    serialize_cgi

    fn serialize_cgi(res : Response) -> Bytes

    Serializes a response the way CGI expects it on stdout: a Status: line, headers sorted by name, a blank line, then the body, all separated by bare LF.

    Content-Length is dropped on purpose: RFC 3875 gives it a special meaning here (a non-parsed body, forwarded raw), which would stop the runner from framing the response at all. Status is dropped for the same reason -- the status line is already written.

    serialize_response

    fn serialize_response(res : Response) -> Bytes

    Serializes to HTTP/1.1 response bytes. content-length is always present, which is what lets the host know where the body ends.