Skip to content

luanvil/mote

Repository files navigation

mote logo

mote

Lightweight Lua HTTP server with routing and middleware.

CI LuaRocks License: MIT

Features

Routing

Parameter extraction, method handlers.

Middleware

CORS, body parsing, rate limiting, JWT support.

Realtime

Server-Sent Events with pub/sub broker.

Async I/O

Coroutine-based with keep-alive. No external event loop.

Installation

luarocks install mote

Tip

Works with Lua 5.1–5.4. LuaJIT recommended for performance.

Quick Start

local mote = require("mote")

mote.get("/", function(ctx)
    ctx.response.body = { message = "Hello, World!" }
end)

mote.get("/users/:id", function(ctx)
    ctx.response.body = { id = ctx.params.id }
end)

mote.post("/echo", function(ctx)
    ctx.response.body = ctx.request.body
end)

local app = mote.create({ port = 8080 })
print("Listening on http://localhost:8080")
app:run()

API

Routing

mote.get(path, handler)
mote.post(path, handler)
mote.put(path, handler)
mote.patch(path, handler)
mote.delete(path, handler)
mote.all(path, handler)

Routes support parameters:

mote.get("/users/:id/posts/:post_id", function(ctx)
    print(ctx.params.id, ctx.params.post_id)
end)

Response

Set response via ctx.response:

ctx.response.body = { id = 1 }   -- auto JSON, auto 200
ctx.response.body = "hello"      -- text/plain
ctx.response.status = 201        -- override status
ctx.response.type = "text/html"  -- override content-type

ctx:set("X-Custom", "val")       -- set response header
ctx:append("Link", "<...>")      -- append to header
ctx:remove("X-Custom")           -- remove response header
ctx:redirect("/login")           -- 302 redirect
ctx:cookie("session", "abc123", { httpOnly = true })
ctx:throw(401, "unauthorized")   -- set status/body and stop
ctx:assert(user, 401, "login required")  -- assert or throw
Static Files
local mime_types = {
    css = "text/css",
    js = "application/javascript",
    png = "image/png",
    svg = "image/svg+xml",
}

mote.get("/static/:file", function(ctx)
    local filename = ctx.params.file
    if filename:match("%.%.") then ctx:throw(400, "invalid path") end

    local f = io.open("./public/" .. filename, "rb")
    if not f then ctx:throw(404, "not found") end

    local data = f:read("*a")
    f:close()

    local ext = filename:match("%.(%w+)$")
    ctx.response.type = mime_types[ext] or "application/octet-stream"
    ctx:set("Content-Disposition", "inline; filename=" .. filename)
    ctx.response.body = data
end)

[!TIP] For production, serve static files via Nginx or a CDN for better performance and caching.

Cookies

Set cookies with ctx:cookie():

ctx:cookie("session", "abc123", {
    path = "/",
    httpOnly = true,
    secure = true,
    sameSite = "Strict",
    maxAge = 86400,
})

Read cookies from ctx.cookies:

local session = ctx.cookies.session
Context Object

The ctx object passed to handlers:

-- Request
ctx.request.method   -- HTTP method
ctx.request.path     -- URL path
ctx.request.headers  -- Request headers (lowercase keys)
ctx.request.body     -- Parsed body (JSON or multipart)
ctx.params           -- Route parameters
ctx.query            -- Parsed query params (lazy)
ctx.cookies          -- Parsed cookies (lazy)
ctx.url              -- Full URL (path + query string)
ctx.ip               -- Client IP address
ctx.user             -- JWT payload (if authenticated)
ctx:get("Header")    -- Get request header (case-insensitive)

-- Response
ctx.response.body    -- Response body (table=JSON, string=text)
ctx.response.status  -- HTTP status (auto 200 with body, 204 without)
ctx.response.type    -- Content-Type override

-- Shared state
ctx.state            -- Pass data between middleware
ctx.config           -- Server config
Middleware

Onion-style middleware with next():

mote.use(function(ctx, next)
    local start = os.clock()
    next()  -- call downstream
    local ms = (os.clock() - start) * 1000
    ctx:set("X-Response-Time", string.format("%.3fms", ms))
end)

mote.use(function(ctx, next)
    if not ctx.user then
        ctx:throw(401, "unauthorized")
    end
    next()
end)
Server-Sent Events
local broker = mote.pubsub.broker

mote.get("/events", function(ctx)
    local client = broker.create_client()
    client:subscribe("messages")
    mote.sse(ctx, client)
end)

broker.broadcast("messages", "new", { text = "Hello!" })
Server Options
local app = mote.create({
    host = "0.0.0.0",
    port = 8080,
    secret = "your-jwt-secret",   -- or set MOTE_SECRET env var
    timeout = 30,
    keep_alive_timeout = 5,
    keep_alive_max = 1000,
    max_concurrent = 10000,
    ratelimit = true,
    reuseaddr = true,             -- allow binding to address in TIME_WAIT
    reuseport = true,             -- allow multiple processes to bind same port
})

app:on_tick(function()
    -- runs every event loop iteration
end)

app:run()
app:stop()          -- immediate shutdown
app:stop(5)         -- graceful: drain connections for 5 seconds
CORS & Rate Limiting
mote.configure_cors({
    origin = "https://example.com",
    methods = "GET, POST",
    headers = "Content-Type, Authorization",
})

mote.ratelimit_configure({
    ["/api/login"] = { max = 10, window = 60 },
    ["*"] = { max = 100, window = 60 },
})
Advanced
-- Manual event loop control (for embedding)
while true do
    app:step(0.1)  -- process events, 100ms timeout
end

-- Monitoring
local count = app.active_connections()

-- Direct client messaging
broker.send_to_client(client_id, "notification", { text = "Hello!" })

-- SSE permission checker (called on each broadcast)
broker.set_permission_checker(function(client, topic, record)
    return client:get_auth() ~= nil  -- only authenticated clients
end)

-- Dynamic subscriptions
client:subscribe("posts")
client:unsubscribe("posts")

-- JWT validation hooks
mote.set_user_validator(function(sub)
    local user = db.get_user(sub)
    if not user then return nil, "user not found" end
    return true
end)
mote.set_issuer_resolver(function()
    return os.getenv("JWT_ISSUER")  -- dynamic issuer for validation
end)

-- Global rate limit override (0 disables rate limiting)
mote.ratelimit_set_global(1000)

-- Logging control
local log = require("mote.log")
log.set_level("debug")  -- debug, info, warn, error
log.disable()           -- or set MOTE_LOG=0 env var

-- Submodules
local parser = require("mote.parser")
local jwt = require("mote.jwt")
local crypto = require("mote.crypto")
local url = require("mote.url")

Deployment

Mote serves HTTP only. For production, use a reverse proxy for TLS termination:

Internet → Caddy/Nginx (HTTPS) → Mote (HTTP)

Cloud platforms like Fly.io, Railway, and Render handle TLS at the edge automatically.

Development

luarocks make         # Build
busted                # Tests
luacheck .            # Lint
stylua .              # Format

Examples

  • ena-api — Compile API for the Ena programming language

Credits

License

MIT

Note

This library was written with assistance from LLMs. Human review and guidance provided where needed.

About

Lua HTTP server with routing and middleware

Topics

Resources

License

Stars

Watchers

Forks