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:3000luarocks install ludiPrebuilt 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_modulesAll 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).
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.
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 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 upgradeThe 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.
| 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 |
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.
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.
LUDI_WATCH=1 lua app.luaWith 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:
- A change is detected (new, edited or deleted
.luafile). - Application modules are dropped from
package.loaded, sorequirere-reads them. The Lua stdlib and ludi itself are kept. - The entrypoint (
app.lua) is re-executed; itsapp: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).
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 WindowsOn 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_WATCHis ignored inside a bundle (nothing on disk to watch).- See ADR 0005 for the design.
┌────────────────────────────┐
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
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 + bustedOr 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.
Tagging v* runs the release workflow: binary rocks are built for each
OS/Lua combination and attached to the GitHub release.