V8 Plugins
The V8 tier runs plugin code inside embedded V8 isolates via rusty_v8. It is the same engine bext already uses for server-side rendering — both PRISM (TSX → HTML strings) and the Next.js compat path (React renderToString/renderToReadableStream) run in V8. So there is no extra runtime to ship, no extra memory cost for the first plugin, and no separate JIT warmup story. Where the QuickJS tier optimizes for tiny footprint and deterministic interpretation, the V8 tier optimizes for raw throughput on middleware that handles every request.
Pick V8 when your plugin does non-trivial work on the hot path: regex-heavy WAF rules, JSON-schema validation, templating, token decoding, or anything where QuickJS's bytecode interpreter becomes the bottleneck.
Runtime Characteristics
| Property | Default | Configurable |
|---|---|---|
| Initial heap | 1 MB | sandbox.limits.initial_heap_bytes |
| Max heap | 64 MB | sandbox.limits.max_heap_bytes |
| Fuel per call | 200 ticks | sandbox.limits.fuel_per_call |
| Watchdog interval | 50 ms | sandbox.limits.tick_interval_ms |
| Cold start (per isolate) | ~15 ms | N/A |
| Per-isolate overhead | ~2 MB resident | N/A |
| Pool warm-up | Eager | pool.size |
On startup, V8PluginPool eagerly builds size isolates so that request-path acquire() never pays a cold-start cost. Each isolate is pinned to its own worker thread for the lifetime of the process — see the Threading Model section below.
The Host Function Table
V8 does not have a linker the way wasmtime does. Instead, every host binding hangs off a single bext global object installed on the isolate's context template. Guest code calls bext.log(...), bext.middleware_continue(), etc. — no imports, no require(), no module resolution.
Payloads that cross the ABI are JSON strings. A middleware handler that wants to short-circuit a response builds a JS object and calls bext.middleware_respond(JSON.stringify(...)). The adapter on the Rust side parses the envelope and turns it into a native RequestAction::Respond(PluginResponse). This keeps the ABI simple, language-agnostic, and trivial to evolve without breaking existing plugins.
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_blocked", 1, JSON.stringify({ reason: "geo" }));
// Read the plugin's config section from bext.config.toml as a JSON string.
const config = JSON.parse(bext.get_config());
// Middleware short-circuit — argument is JSON with {status, headers, body}.
return bext.middleware_respond(JSON.stringify({
status: 403,
headers: [["content-type", "text/plain"]],
body: "forbidden",
}));
// Middleware continue — no arguments, always returns the continue envelope.
return bext.middleware_continue();
// Lifecycle hook — name and arbitrary JSON payload for observability.
bext.lifecycle_event("request_complete", JSON.stringify({ path, status }));
// Cache-backend observability — emit hit/miss/evict events to tracing.
bext.cache_event("hit", key);
// Transform plugin "no change" marker.
return bext.transform_unchanged();
The bindings above cover the four capability traits currently wired for V8: Middleware, Lifecycle, Transform, and Cache. The remaining foundational (E1) and mid-tier (E2) caps land in a follow-up — see KNOWN_DIFFERENCES.md in the bext-plugin-v8 crate for the exact matrix.
Writing a V8 Plugin
A V8 plugin's entry point is a single expression that evaluates to a plain object. The adapter calls methods on that object by name — on_request, on_request_complete, matches, transform, etc. — and expects each method to return a JSON string (or null).
// plugin.js — returned expression is the plugin object.
({
on_request(ctx) {
const parsed = JSON.parse(ctx);
if (parsed.path.startsWith("/admin") && !parsed.headers.find(([k]) => k === "authorization")) {
return bext.middleware_respond(JSON.stringify({
status: 401,
headers: [["www-authenticate", "Bearer"]],
body: "unauthorized",
}));
}
return bext.middleware_continue();
},
on_request_complete(event) {
const parsed = JSON.parse(event);
bext.emit_metric("request_latency_ms", parsed.duration_ms, "{}");
bext.lifecycle_event("request_complete", event);
return null;
},
})
The RequestContext passed to on_request is serialized as JSON before the call and contains method, path, hostname, headers, query_string, peer_ip, tenant_id, site_id, and the opaque extensions bag.
Sandbox Enforcement
V8 does not expose wasmtime-style per-instruction fuel. The V8 tier approximates it:
1. At the top of every guest call, the adapter writes fuel_per_call into a shared AtomicI64.
2. A watchdog thread pings the isolate via v8::Isolate::request_interrupt every tick_interval_ms milliseconds.
3. The interrupt callback decrements the fuel counter; when it goes non-positive, it calls terminate_execution on the isolate.
4. The main thread observes TerminatedForFuel as a normal Rust Err. After returning, the adapter calls cancel_terminate_execution so the isolate is reusable for the next request.
This gives an approximate wall-clock ceiling, not a deterministic instruction count. A script that enters a tight loop will be killed within one watchdog interval (~50ms by default) regardless of what it is doing. A script that blocks on JSON parsing of a pathological input will also be killed, but on a best-effort basis.
Memory is bounded by V8's standard CreateParams::heap_limits mechanism: exceeding max_heap_bytes kills the isolate via the near-heap-limit callback. See Known Differences below for current caveats on this knob.
Threading Model
Each V8Plugin owns exactly one isolate, pinned to a dedicated worker thread created at plugin-construction time. Calls from the request path are funneled through an mpsc channel — the caller thread sends a Request { method, arg_json, reply } struct and blocks on the reply channel until the worker returns the JSON result.
Why not run the isolate on the caller thread? Two reasons:
1. Thread affinity. V8 requires that an isolate be entered from the same OS thread that created it (or explicitly locked/unlocked across threads, which we don't do). Tying the isolate to a long-lived worker is the simplest way to hold that invariant.
2. Watchdog decoupling. The watchdog's request_interrupt call needs a stable IsolateHandle. A pinned worker thread + leaked watchdog thread means the handle is trivially reachable from the watchdog for the lifetime of the plugin.
V8PluginPool sits on top of this and pre-spawns size instances. Acquisitions are tracked for test assertions so the integration suite can verify reuse across calls.
Complete Example: Bearer Token Guard
// plugin.js
({
_config: null,
on_server_start(config_json) {
this._config = JSON.parse(config_json);
bext.log("info", `bearer-guard: protecting ${this._config.prefixes.join(", ")}`);
return null;
},
on_request(ctx_json) {
const ctx = JSON.parse(ctx_json);
const protectedPath = this._config.prefixes.some((p) => ctx.path.startsWith(p));
if (!protectedPath) {
return bext.middleware_continue();
}
const authHeader = ctx.headers.find(([k]) => k.toLowerCase() === "authorization");
if (!authHeader || !authHeader[1].startsWith("Bearer ")) {
bext.emit_metric("bearer_guard_denied", 1, JSON.stringify({ reason: "missing" }));
return bext.middleware_respond(JSON.stringify({
status: 401,
headers: [["www-authenticate", "Bearer realm=api"]],
body: "missing bearer token",
}));
}
const token = authHeader[1].slice("Bearer ".length);
if (!this._config.allowed_tokens.includes(token)) {
bext.emit_metric("bearer_guard_denied", 1, JSON.stringify({ reason: "invalid" }));
return bext.middleware_respond(JSON.stringify({
status: 403,
headers: [],
body: "invalid token",
}));
}
return bext.middleware_continue();
},
})
Corresponding bext.config.toml:
[plugins.bearer-guard]
sandbox = "v8"
path = "./plugins/bearer-guard/plugin.js"
[plugins.bearer-guard.config]
prefixes = ["/api/", "/admin/"]
allowed_tokens = ["tok_live_xxx", "tok_live_yyy"]
[plugins.bearer-guard.sandbox.limits]
max_heap_bytes = 33554432
fuel_per_call = 500
tick_interval_ms = 25
When to Pick V8 vs QuickJS vs WASM
| Need | Pick |
|---|---|
| Tiny footprint, dozens of plugins per server | QuickJS |
| JavaScript on the hot path, <1ms budget | V8 |
| Non-JS languages (Rust, Go, AssemblyScript) | WASM |
| Deterministic execution, auditable fuel | WASM |
| Share one runtime with React SSR | V8 |
| Strict memory isolation with OS-level sandbox | nsjail |
The V8 tier is deliberately the heaviest JS option — reach for it when you are already using bext's V8-based SSR and want to amortize the engine cost across your plugin surface too.
Known Differences
The V8 tier is a first-class plugin runtime, but it is not yet a drop-in replacement for the wasmtime tier. The authoritative list of gaps (approximate fuel semantics, deferred E1/E2 capability bindings, the worker-thread leak, and the single-integration-test pattern we use to work around a rusty_v8 v130 test-harness bug) is maintained alongside the crate at crates/bext-plugin-v8/KNOWN_DIFFERENCES.md. Read it before deploying a V8 plugin to production.