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.