Lua Plugins
The Lua tier runs plugin code inside embedded Lua 5.4 states via mlua. It is the smallest, fastest-starting plugin runtime bext ships — a bare state costs ~150 KB of memory and evaluates a small script in under a millisecond. Where the V8 tier is optimized for raw JS throughput on middleware that handles every request, the Lua tier is optimized for hot-reloadable scripting: policies, transforms, rate limits, and data-munging steps that tenants may want to edit without going through a deploy.
When to pick Lua
Pick Lua when:
- You want hot-reloadable per-tenant scripts. Lua cold-start is low enough that you can rebuild the state on a config change without measurable request-path impact.
- Your plugin is small and self-contained. A rate limiter, a policy decision, an outbound request rewriter — anything that fits in a page of script.
- You need the smallest possible memory overhead. 150 KB per state means you can keep one-per-tenant in memory for thousands of tenants.
- You prefer a tiny, stable language surface. Lua 5.4 has no package manager, no macros, and no npm install rabbit hole — what you see in the script is what runs.
Pick something else when:
- You need modern JavaScript features, async/await, or the broader NPM ecosystem — use V8 Plugins.
- You need near-native compiled speed for CPU-heavy code — use WASM Plugins.
- You need a full Linux userspace (shelling out, linking libraries) — use nsjail Plugins.
Runtime Characteristics
| Property | Default | Configurable |
|---|---|---|
| Max heap | 64 MB | LuaSandboxLimits::max_heap_bytes |
| Fuel per call | 200 ticks | LuaSandboxLimits::fuel_per_call |
| Hook count (VM instructions per tick) | 1,000 | LuaSandboxLimits::hook_count |
| Cold start (per state) | ~1 ms | N/A |
| Per-state overhead | ~150 KB resident | N/A |
| Pool warm-up | Eager | pool.size |
On startup, LuaPluginPool eagerly builds size states so that request-path acquire() never pays a cold-start cost. Unlike the V8 tier, Lua states are not pinned to dedicated worker threads — mlua has no drop-order constraints and the adapter simply guards the state with a mutex.
The Host Function Table
Guest scripts call host functions through a single bext global table installed at adapter-construction time. This mirrors the V8 tier's bext.* namespace exactly so authors can transliterate between runtimes.
Payloads that cross the ABI are JSON strings. A middleware handler that wants to short-circuit a response builds a JSON string and passes it to bext.middleware_respond(...); the Rust adapter parses the envelope and turns it into a native RequestAction::Respond(PluginResponse).
Available Bindings
-- Structured logging — routed to the host tracing pipeline.
bext.log("info", "request seen")
bext.log("warn", "slow DB query")
bext.log("error", "upstream 502")
-- Emit a metric — name, numeric value, optional JSON-stringified tags.
bext.emit_metric("requests_total", 1, '{"route":"/api/health"}')
-- Read the plugin's config section from bext.config.toml (returns a JSON string).
local cfg = bext.get_config()
-- Short-circuit a middleware request with a response envelope.
return bext.middleware_respond(
'{"status":429,"headers":[["retry-after","60"]],"body":"slow down"}'
)
-- Continue down the middleware stack.
return bext.middleware_continue()
-- Cache observability — emits a structured event on the host tracing bus.
bext.cache_event("hit", "k1")
-- Lifecycle event — used by guest LifecyclePlugin scripts to log hook firings.
bext.lifecycle_event("request_complete", event_json)
-- Transform unchanged marker — guest TransformPlugin scripts use this to say
-- "this file wasn't rewritten".
return bext.transform_unchanged()
The following capability traits are wired today: Middleware, Cache, Lifecycle, Transform. The E1 foundational capabilities (Auth, Session, Mailer, Tracer, Scheduled) and the E2 mid-tier capabilities (Webhook, FeatureFlag, I18n, StorageClient, SearchClient, AuthzPolicy, Locking) are deferred to a follow-up session — see crates/bext-plugin-lua/KNOWN_DIFFERENCES.md and the TODO(E4 wave 2) markers in host_functions.rs.
Hello World — a Middleware plugin
A Lua plugin is a script that evaluates to a table whose entries are the capability trait methods. The adapter loads the script once, caches the returned table in the Lua registry, and calls its methods on every request.
-- my-middleware.lua
return {
on_request = function(self, ctx_json)
-- ctx_json is a serialised RequestContext. Decode what you need
-- with a Lua-side JSON library or a cheap substring match.
bext.log("info", "plugin saw: " .. ctx_json:sub(1, 80))
return bext.middleware_continue()
end,
on_response = function(self, arg)
-- arg is {"status":N,"headers":[[k,v]...]}
-- Return nil to leave the response unchanged.
return nil
end,
}
Load it from a Rust host:
use bext_plugin_lua::{LuaPluginConfig, LuaPluginRuntime};
use bext_plugin_api::types::PluginManifest;
let rt = LuaPluginRuntime::new();
let source = std::fs::read_to_string("my-middleware.lua")?;
let manifest = PluginManifest {
name: "my-middleware".into(),
version: "0.1.0".into(),
description: "".into(),
capabilities: vec![],
requires_capabilities: vec![],
provides_capabilities: vec![],
pure: false,
state: None,
lazy: false,
};
rt.load_plugin(LuaPluginConfig::new("my-middleware", manifest, source))?;
Reference Plugin: bext-ratelimit-lua
crates/bext-impls/bext-ratelimit-lua ships a small IP-based sliding-window rate limiter authored entirely in Lua. The Rust wrapper is ~50 lines that embed the script.lua file via include_str! and expose a new() helper. This is the recommended shape for reference Lua plugins.
crates/bext-impls/bext-ratelimit-lua/
├── Cargo.toml -- bext-plugin-api + bext-plugin-lua path deps
├── script.lua -- the actual plugin logic (~50 lines of Lua)
└── src/lib.rs -- thin Rust loader: pub const SCRIPT + pub fn new()
Run its tests:
cargo test -p bext-ratelimit-lua
Comparison with V8 and WASM
| Dimension | Lua | V8 | wasm |
|---|---|---|---|
| Language | Lua 5.4 | Modern JavaScript | Rust / Go / C (compiled) |
| Cold start (per state) | ~1 ms | ~15 ms | ~1 ms |
| Per-instance memory | ~150 KB | ~2 MB | ~500 KB |
| Async support | No | Deferred | No (wasi-preview1) |
| Hot reload | Yes (new state per reload) | No (drop bug) | Yes |
| JIT | No (interpreter only) | Yes | Yes (Cranelift) |
| Stdlib size | Very small | Very large | Medium (wasi) |
| Best fit | Per-tenant scripts, policy checks | Hot-path middleware, complex data munging | CPU-heavy, compiled code |
Known Differences
See crates/bext-plugin-lua/KNOWN_DIFFERENCES.md for the full list of current deviations from the wasmtime and V8 tiers, including the 13 unbound E1/E2 capability slots, fuel-approximation caveats, and the stdlib hardening follow-up.