Skip to content
Ludi-FrameworkPublic

About

Web framework for Lua, powered by Rust

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Ludi

An Express-style web framework for Lua, powered by Rust.

Routing, middlewares, handlers and WebSockets are plain Lua. Underneath, a native module written in Rust (hyper + tokio) does the HTTP and WebSocket heavy lifting. No OpenResty, no nginx, no separate runtime — just require("ludi") and run your app with any Lua interpreter.

local ludi = require("ludi")

local app = ludi.new()

app:get("/users/:id", function(req, res)
    res:json({ id = req.params.id })
end)

app:listen(3000, function()
    print("Listening on http://localhost:3000")
end)
lua app.lua
# Listening on http://localhost:3000

Installation

luarocks install ludi

Prebuilt binary rocks are published for Linux, macOS and Windows (Lua 5.4, 5.5 and LuaJIT), so no Rust toolchain is needed. On platforms without a prebuilt rock, LuaRocks falls back to building from source, which requires Rust installed.

For a per-project install (like node_modules):

luarocks install ludi --tree lua_modules

Guide

Routing

All standard HTTP methods are available: get, post, put, delete, patch, options, head.

app:get("/hello", function(req, res)
    res:send("hi")
end)

app:post("/items", function(req, res)
    res:status(201):json({ ok = true })
end)

Path segments prefixed with : are captured as params:

app:get("/users/:id/posts/:post_id", function(req, res)
    res:json({ user = req.params.id, post = req.params.post_id })
end)

Unmatched requests get a 404 with a JSON body. Errors thrown inside a handler are caught and answered with a 500 (the error is logged to stderr, never leaked to the client).

Middlewares

A middleware is a function (req, res, next). The chain only advances when next() is called — not calling it halts the request, express-style.

-- global: runs for every request
app:use(function(req, res, next)
    print(req.method .. " " .. req.path)
    next()
end)

-- per route: single function or a list, before the handler
local function auth(req, res, next)
    if req.headers["authorization"] then
        next()
    else
        res:status(401):json({ error = "Unauthorized" })
    end
end

app:get("/private", auth, function(req, res)
    res:send("secret")
end)

app:get("/very-private", { auth, audit }, function(req, res)
    res:send("very secret")
end)

Execution order: global middlewares → group middlewares → route middlewares → handler.

Route groups

app:group(prefix) gathers routes under a shared prefix — like an Express Router mounted on a path, or a Fastify plugin registered with a prefix:

app:group("/api", function(api)
    api:get("/users", list_users)       -- GET /api/users
    api:post("/users", create_user)     -- POST /api/users

    api:group("/v2", function(v2)       -- groups nest
        v2:get("/users", list_users_v2) -- GET /api/v2/users
    end)
end)

The group is also returned, so the callback is optional. A middleware list between the prefix and the callback runs before every route in the group; group:use adds one for the routes registered after the call:

app:group("/admin", { auth }, function(admin)
    admin:get("/stats", stats)          -- auth runs first
end)

local reports = app:group("/reports")
reports:use(auth)
reports:get("/daily", daily)

A lone function after the prefix is always the callback — wrap a single middleware in a table ({ auth }) or use group:use.

WebSockets

WebSockets are built in — no extra library, same port as HTTP. A ws route takes a handler that runs once per connection, right after the handshake:

app:ws("/chat/:room", function(conn, req)
    print("joined " .. req.params.room)

    conn:on("message", function(data, binary)
        conn:send("echo: " .. data)
    end)

    conn:on("close", function(code, reason)
        print("left " .. req.params.room)
    end)
end)

The connection object:

Method / field Description
conn:on(event, fn) Listeners for "message" (data, binary), "close" (code, reason) and "error" (err) — err.message holds the text
conn:send(data, binary?) Sends a text frame (or binary with true); returns false once closed
conn:close(code?, reason?) Starts closing; code defaults to 1000
conn.closed true once the "close" event has fired — always the last event a connection emits

Middlewares — global, per-route and via groups — run at handshake time with the usual (req, res, next) signature. Responding instead of calling next() rejects the handshake with that HTTP response:

app:ws("/private", { auth }, function(conn) ... end)
-- without the Authorization header the client gets the 401, no upgrade

The req the handler receives is the same object the middlewares saw, so anything they attach travels with it:

local function auth(req, res, next)
    req.user = lookup(req.headers["authorization"])
    if req.user then next() else res:status(401):send("no") end
end

app:ws("/private", { auth }, function(conn, req)
    conn:send("hello " .. req.user.name)
end)

Requests to a ws path without the upgrade headers fall through to the regular router (and its 404). Handler errors close the connection with 1011 and are logged like HTTP handler errors. Messages are capped at 1 MB, like HTTP bodies.

Request

Field / method Description
req.method HTTP method, uppercase ("GET")
req.path Path without query string ("/users/42")
req.params Path params captured by the router ({ id = "42" })
req.query Parsed query string (?a=1 → { a = "1" })
req.headers Request headers, lowercase keys (req.headers["content-type"])
req.body Raw body as a string (up to 1 MB; larger requests get 413)
req:json() Decodes the body as JSON; returns a table, or nil plus an error

Response

All methods are chainable.

Method Description
res:status(code) Sets the status code (default 200)
res:header(name, value) Sets a response header
res:send(data) String → text/plain; table → JSON with application/json
res:json(data) Alias of send for tables

Explicit headers win over the Content-Type that send sets by default.

Listening

app:listen(3000)   -- blocks; defaults to 3000 when omitted

app:listen(3000, function()   -- callback runs once the port is bound,
    print("Ready!")           -- before any request is served
end)

Ludi prints nothing on startup — logging is the application's choice, in the callback. A port that cannot be bound raises a Lua error.

Hot reload (development)

LUDI_WATCH=1 lua app.lua

With LUDI_WATCH=1, Ludi watches every *.lua file under the working directory and hot-reloads the application on change — no restart, no rebinding the port, in-flight requests are never interrupted:

  1. A change is detected (new, edited or deleted .lua file).
  2. Application modules are dropped from package.loaded, so require re-reads them. The Lua stdlib and ludi itself are kept.
  3. The entrypoint (app.lua) is re-executed; its app:listen() call is intercepted and the new app replaces the old one in place.

A reload that fails — syntax error, error at load time — is logged to stderr and the previous version keeps serving until the next change. State living in module locals is reset on reload, like restarting the process; the listen callback does not run again.

Intended for development only: leave LUDI_WATCH unset in production (the watcher polls the filesystem and reloads on any deploy write).

Building a binary

Like Go, a Ludi application can ship as a single self-contained executable — no Lua, no LuaRocks on the target machine:

ludi build                    # entry auto-detected when unambiguous
ludi build server.lua -o api  # or explicit entry and output name
./api                         # api.exe on Windows

On Windows the output always gets an .exe extension (so ludi build produces myapp.exe), since that is what the OS needs to launch it.

Without an explicit entry, ludi build accepts the conventional names app.lua, server.lua, main.lua and init.lua — but only when exactly one of them exists. With two or more present there is no safe guess (an app.lua next to a server.lua is usually a module, not the runner), so the build asks for the entrypoint instead of guessing wrong.

ludi build packs every *.lua file under the current directory (same skip rules as the watcher) into a copy of the ludi runtime: a static Lua 5.5, the framework and the application in one file. At run time require resolves from the bundle; nothing is read from disk. The build is instant — no toolchain involved.

The ludi CLI is attached to each GitHub release for Linux, macOS and Windows across x86_64, arm64 and x86 (e.g. ludi-linux-x86_64, ludi-darwin-arm64, ludi-windows-x86_64.exe), or build it from source with make cli.

Notes:

  • A bundled binary always runs Lua 5.5, regardless of the interpreter used in development.
  • Modules resolve from the bundle first, then from the host's normal LuaRocks trees — so native rocks (e.g. fredy_core) work, but must be installed on the target machine. Pure-Lua apps run anywhere. On Windows a native rock inside a bundle must have been built against the same Lua 5.5 the runtime embeds.
  • LUDI_WATCH is ignored inside a bundle (nothing on disk to watch).
  • See ADR 0005 for the design.

Architecture

                    ┌────────────────────────────┐
 HTTP clients ───▶  │  Rust: hyper + tokio       │   ludi_core (native module)
                    │  worker threads            │
                    └────────────┬───────────────┘
                          mpsc channel (one Job per request)
                    ┌────────────▼───────────────┐
                    │  Lua thread (your app)     │   routing, middlewares,
                    │  single-threaded, Node-like│   handlers — plain Lua
                    └────────────────────────────┘
  • Rust is transport only: it parses HTTP, speaks the WebSocket protocol, enforces limits, and forwards each request and frame through a channel. It knows nothing about routes.
  • All Lua runs on the single thread that called app:listen(). Worker threads never touch the interpreter, so there are no cross-thread callbacks and no FFI.
  • Each hyper worker awaits its response on a oneshot channel, so slow handlers don't block connection accept.

Design decisions and the planned evolution (async stdlib, worker model) are documented in docs/architecture.md and docs/adr/.

Repository layout (same shape as Lapis):

ludi/          Lua package (init, router, request, response, middleware, json, reload)
src/           Rust native module (lib, server, bridge, types, watch)
spec/          busted specs
examples/      runnable examples

Development

Requires Rust, a Lua (5.1+ or LuaJIT — 5.5 recommended) with headers, and busted for the Lua specs.

make dev              # build the native module for Lua 5.5 (recommended)
make dev LUA=lua54    # ... or for Lua 5.4
make dev LUA=luajit   # ... or for LuaJIT
make run              # build + run examples/hello.lua
make test             # cargo test + busted

Or via LuaRocks: luarocks make builds and installs the rockspec locally, and luarocks test runs the busted suite. If require("ludi") isn't found after installing, load the LuaRocks paths into your shell: eval "$(luarocks path)".

The Lua version is selected by a cargo feature, e.g. cargo build --release --features lua54. When installing through LuaRocks, luarocks-build-rust-mlua picks the right feature automatically.

On Windows, make dev and make run work under Git Bash (they use a .dll and copy the module into place instead of symlinking). Building from source through LuaRocks needs LuaRocks 3.11 or newer — older releases can't invoke the Rust build backend on Windows. Prebuilt rocks have no such requirement.

Releases

Tagging v* runs the release workflow: binary rocks are built for each OS/Lua combination and attached to the GitHub release.

License

MIT

About

Web framework for Lua, powered by Rust

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages