Sign in

    moonway

    A full-stack web framework for MoonBit

    web
    framework
    fullstack
    Download zip
    Author
    Version
    0.11.7
    License
    MIT
    Last updated
    10 days ago
    Downloads
    37

    #moonway

    The road to the moon.

    A full-stack web framework for MoonBit — routing, database, caching, WebSocket, an auto-generated admin dashboard, and more. Everything a modern web app needs, designed to work together from your first moon new.

    #Why moonway?

    MoonBit is fast, type-safe, and a joy to write — but assembling a real web application today means picking a router, wiring a database client, bolting on a cache, and writing the glue yourself. moonway is batteries-included: one cohesive framework where the pieces of a modern web app are designed to fit together, so you can go from idea to running application in minutes.

    The name is the mission: moon + way — the road to the moon.

    #Features

    • HTTP routing — expressive routes with path parameters and a composable middleware pipeline.
    • Database layer — a typed data abstraction with migrations and a fluent query API. SQLite works out of the box.
    • Caching — Redis-backed response caching with a configurable TTL (app.cache(...)); requests are served untouched when Redis is unavailable.
    • WebSocket — first-class support for realtime features like chat and live updates.
    • Admin dashboard — a CRUD admin UI generated from your models. Zero frontend work required.
    • REPL console — an interactive console to query data and poke at your running app.
    • Code generator — scaffolding for models, handlers, and more, so boilerplate never slows you down.
    • Frontend included — an integrated Preact workflow for the client side of your app.

    #Quick start

    Install the MoonBit toolchain, then:

    moon new hello-moonway cd hello-moonway # moonway is a server-side framework: switch the project to the native backend awk '{sub(/preferred_target = "wasm"/, "preferred_target = \"native\"")} 1' moon.mod > moon.mod.tmp && mv moon.mod.tmp moon.mod moon add daqing/moonway # register the dependency in the main package too printf 'import {\n "daqing/moonway",\n}\n' >> cmd/main/moon.pkg

    Give your app a route:

    ///|
    fn main {
    let app = @moonway.new()
    app.get("/", ctx => ctx.text("Hello, moonway!"))
    app.listen(3000)
    }

    Run it:

    moon run cmd/main

    Open http://localhost:3000. You're on the road. 🌙

    #The full stack, in one file

    Replace cmd/main/moon.pkg with (the link flags are required — moon add does not inherit them from dependencies; Linux also needs apt install libsqlite3-dev, macOS ships SQLite):

    import { "daqing/moonway", "daqing/moonway/db", "moonbitlang/core/json", } supported_targets = "native" pkgtype(kind: "executable") options( link: { "native": { "cc-link-flags": "-lsqlite3" } }, )

    Then replace cmd/main/main.mbt with:

    ///|
    struct Post {
    id : Int32
    title : String
    body : String
    } derive(ToJson, FromJson)

    ///|
    let posts : @db.Table = @db.table("posts", [
    @db.column("id", @db.IntT, primary=true),
    @db.column("title", @db.TextT),
    @db.column("body", @db.TextT),
    ])

    ///|
    fn main {
    let app = @moonway.new()
    let db = @moonway.sqlite("app.db")
    if !db.migrate([@db.migration(1, "create posts", [posts.create_sql()])]) {
    abort("migrations failed")
    }
    app.get("/posts", ctx => {
    let items : Array[Json] = db.select_raw(posts, "1=1", [], limit=100)
    ctx.json(Json::array(items))
    })
    app.post("/posts", ctx => {
    let row : Json? = Some(@json.parse(ctx.body())) catch { _err => None }
    match row {
    Some(Object(fields)) =>
    if db.insert_raw(posts, fields) {
    ctx.status(201).json(Json::object(fields))
    } else {
    ctx.status(500).text("insert failed")
    }
    _ => ctx.status(400).text("invalid JSON body")
    }
    })
    // A ready-to-use admin UI for your data, served at /admin
    app.admin(db, [posts])
    app.listen(3000)
    }

    #The toolkit

    #Generator

    moonway generate scaffold Post title:string body:string

    created models/post.mbt created handlers/posts.mbt created migrations/001_create_posts.mbt updated admin registry

    The moonway CLI ships with the framework — install it with moon install daqing/moonway.

    #Admin dashboard

    Admin dashboard

    Every registered model gets a searchable, paginated CRUD interface at /admin — create, edit, and inspect your data without writing a line of frontend code.

    #REPL console

    $ moonway console moonway 0.1.0 · connected to sqlite://app.db moonway> Post.count() 42

    #Roadmap

    moonway is being built in the open for the October 2026 MoonBit hackathon (final submission: October 31, 2026).

    #Contributing

    Contributions are welcome! Open an issue to talk about what you'd like to build, or submit a pull request directly.

    Before committing, install the git hooks (formatting and tests run on every commit):

    git config core.hooksPath .githooks

    #License

    MIT © 2026 David Zhang

    App

    pub struct App {
    routes : Array[Route]
    middlewares : Array[(Context) -> Context]
    cache_policy :
    CachePolicy
    [
    RedisClient
    ]?
    ws_routes : Array[
    WsRoute
    ]
    static_dir : String?
    }

    App::admin

    fn App::admin(self : App, db :
    Database
    , tables : Array[
    Table
    ], prefix? : String, token? : String) -> Unit

    Mount the admin dashboard at prefix (default /admin) for tables, reading and writing through db. When token~ is non-empty, every admin request must carry it as ?token=... (or the hidden form field on POSTs) — basic protection, not production-grade auth.

    App::cache

    fn App::cache(self : App, client :
    RedisClient
    , ttl? : Int, prefix? : String) -> Unit

    Enable response caching for GET requests through a Redis client: hits are served before middlewares and handlers (X-Cache: HIT); 200 responses are stored with the configured TTL (X-Cache: MISS). Cache failures degrade to plain dispatch.

    App::delete

    fn App::delete(self : App, path : String, handler : (Context) -> Context) -> Unit

    App::get

    fn App::get(self : App, path : String, handler : (Context) -> Context) -> Unit

    App::handle

    fn App::handle(self : App, verb : String, path : String, body? : String) ->
    Response

    App::listen

    fn App::listen(self : App, port : Int) -> Unit

    App::middleware

    fn App::middleware(self : App, middleware : (Context) -> Context) -> Unit

    Register a middleware. Middlewares run in registration order, before the matched route handler; each receives and returns the context. A middleware may short-circuit the pipeline with ctx.halt(): later middlewares and the route handler are skipped.

    App::patch

    fn App::patch(self : App, path : String, handler : (Context) -> Context) -> Unit

    App::post

    fn App::post(self : App, path : String, handler : (Context) -> Context) -> Unit

    App::put

    fn App::put(self : App, path : String, handler : (Context) -> Context) -> Unit

    App::static_files

    fn App::static_files(self : App, dir : String) -> Unit

    Serve static files from dir as a fallback for unmatched GET requests: / maps to index.html, directories to their index.html, and path-traversal attempts are rejected. Dynamic routes always win.

    App::ws

    fn App::ws(self : App, path : String, handler : WsHandler) -> Unit

    Register a WebSocket route: connections to path are upgraded and the framework runs the receive loop, invoking the handler's events.

    Context

    pub struct Context {
    request :
    Request

    params : Map[String, String]
    response :
    Response

    halted : Bool
    }

    Context::body

    fn Context::body(self : Context) -> String

    Context::halt

    fn Context::halt(self : Context) -> Context

    Short-circuit the middleware pipeline: later middlewares and the route handler are skipped, and the response built so far is returned as-is.

    Context::header

    fn Context::header(self : Context, name : String, value : String) -> Context

    Context::html

    fn Context::html(self : Context, body : String) -> Context

    Context::json

    fn[T : ToJson] Context::json(self : Context, value : T) -> Context

    Context::param

    fn Context::param(self : Context, name : String) -> String?

    Context::path

    fn Context::path(self : Context) -> String

    Context::query_param

    fn Context::query_param(self : Context, name : String) -> String?

    Look up a query-string parameter (?name=value) by name.

    Context::request_header

    fn Context::request_header(self : Context, name : String) -> String?

    Context::status

    fn Context::status(self : Context, code : Int) -> Context

    Context::text

    fn Context::text(self : Context, body : String) -> Context

    Context::verb

    fn Context::verb(self : Context) -> String

    Route

    pub struct Route {
    verb : String
    path : String
    handler : (Context) -> Context
    }

    WsConn

    pub struct WsConn {
    conn :
    Conn

    id : Int
    }

    A WebSocket connection handed to user handlers.

    WsConn::close

    async fn WsConn::close(self : WsConn) -> Unit

    Close the connection politely.

    WsConn::recv_text

    async fn WsConn::recv_text(self : WsConn) -> String

    Receive the next message as text. Binary messages are decoded lossily. Raises when the connection closes.

    WsConn::send_text

    async fn WsConn::send_text(self : WsConn, text : String) -> Unit

    Send a text message.

    WsHandler

    pub(all) struct WsHandler {
    on_message : async (WsConn, String) -> Unit
    on_close : async (WsConn) -> Unit
    }

    Event handlers for a WebSocket connection. The framework runs the receive loop: on_message fires per text message, and on_close fires exactly once when the connection closes or errors.

    WsHub

    pub struct WsHub {
    conns : Array[WsConn]
    }

    A broadcast hub: register connections as they open and broadcast text to all of them. Runs inside the single-threaded event loop, so no locking is needed. Failed sends are dropped silently.

    WsHub::broadcast

    async fn WsHub::broadcast(self : WsHub, text : String) -> Unit

    WsHub::join

    fn WsHub::join(self : WsHub, conn : WsConn) -> Unit

    WsHub::leave

    fn WsHub::leave(self : WsHub, conn : WsConn) -> Unit

    WsHub::new

    fn WsHub::new() -> WsHub

    new

    fn new() -> App

    run_ws

    async fn run_ws(handler : WsHandler, conn :
    Conn
    ) -> Unit

    Drive the event loop of a single WebSocket connection with a handler. Used by App::ws; exposed for tests and custom serving setups.

    sqlite

    fn sqlite(path : String) ->
    Database

    Open the SQLite database for your app (see the README full-stack example). A failed open is fatal at startup.