tutorial

Building Your First bext Plugin in 10 Minutes

bext's plugin system lets you extend the server without forking the codebase. Plugins can intercept requests, transform responses, implement custom cache backends, or hook into the deployment lifecycle.

There are three plugin tiers, each with different tradeoffs:

TierLanguagesStartupIsolationUse for
QuickJSJavaScript<1msIn-engine JS sandboxQuick middleware, transforms, rules
WASMRust, Go, C, AssemblyScript<1msMemory-safe Wasmtime sandboxCompiled or perf-critical logic
nsjailPython, Shell, any binary~10msOS process isolation (nsjail)Heavier tools and existing scripts

This tutorial uses the QuickJS runtime — the fastest way to get a JavaScript plugin running. The concepts apply to all three.

What Plugins Can Do

Plugins implement one or more interfaces:

  • MiddlewarePlugin — runs before the request handler (auth, rate limiting, header injection)
  • TransformPlugin — modifies the response body (HTML rewriting, asset injection)
  • CacheBackend — provides a custom cache store (Redis, Memcached, S3)
  • LifecyclePlugin — hooks into deploy, startup, and shutdown events

Middleware and transform plugins sit on either side of your route handler:

flowchart LR
  Req(["Request"]) --> MW["Middleware (on_request)"]
  MW -->|null: continue| H["Route handler"]
  MW -->|response: short-circuit| OUT(["Response"])
  H --> TR["Transform (rewrite HTML)"]
  TR --> MR["Middleware (on_response)"]
  MR --> OUT
  classDef p fill:#fff1f2,stroke:#f43f5e,color:#0c0c0c,stroke-width:1.5px
  class MW,TR,MR p

Step 1: Create the Files

A plugin is just a directory with a manifest and an entry file. Create plugins/my-header-injector/:

Code
plugins/my-header-injector/
  manifest.toml
  index.js

Step 2: Write the Plugin

Open index.js:

JavaScript
// Middleware plugin: add custom headers to every response
export function onRequest(req) {
  // Return null to continue to the next handler
  return null;
}

export function onResponse(req, res) {
  // Add a custom header
  res.headers["X-Powered-By"] = "bext";
  res.headers["X-Request-Id"] = crypto.randomUUID();
  return res;
}

The plugin API is intentionally simple. onRequest receives the request and can return a response (short-circuit) or null (continue). onResponse receives both and can modify the response before it's sent.

Step 3: Configure

In manifest.toml:

TOML
name = "my-header-injector"
version = "0.1.0"
type = "MiddlewarePlugin"
tier = "quickjs"
priority = 100

[permissions]
network = false
filesystem = false

The priority field controls execution order — lower numbers run first. Permissions are opt-in: a plugin that doesn't need network access doesn't get it.

Step 4: Run It

bext loads plugins from your project's plugins/ directory at startup, so there's nothing to install for a local plugin — just run the app:

Shell
bext run .

Now every response includes X-Powered-By: bext and a unique request ID.

A More Useful Example: Bot Protection

JavaScript
// Block known bad bots based on User-Agent patterns
const BAD_BOTS = [
  /AhrefsBot/i,
  /SemrushBot/i,
  /MJ12bot/i,
  /DotBot/i,
];

export function onRequest(req) {
  const ua = req.headers["user-agent"] || "";
  
  for (const pattern of BAD_BOTS) {
    if (pattern.test(ua)) {
      return {
        status: 403,
        headers: { "Content-Type": "text/plain" },
        body: "Forbidden",
      };
    }
  }
  
  return null; // Allow request to continue
}

This runs in a QuickJS sandbox — even if the bot regex has a catastrophic backtracking bug, it can't crash the server. The sandbox enforces a CPU time limit (default: 50ms per request).

Transform Plugin: Inject Analytics

JavaScript
// Inject a script tag before </body>
const SCRIPT = '<script defer src="https://analytics.example.com/script.js"></script>';

export function transform(html) {
  return html.replace("</body>", SCRIPT + "</body>");
}

Transform plugins receive the rendered HTML and return modified HTML. They run after SSR but before compression, so the overhead is minimal.

Sharing Your Plugin

A plugin in your plugins/ directory is yours alone. To share one, compile it to the portable format — a single .wasm file — and distribute it through registry.bext.dev.

Before shipping, inspect the built artifact to review its manifest, declared permissions, and size:

Shell
bext plugins inspect my-header-injector.wasm

Anyone can then drop it into their own server:

Shell
bext plugins install my-header-injector.wasm

What's Next

For WASM plugins (Rust, Go, C), see the WASM plugin guide. For nsjail plugins (Python, Shell), see the nsjail guide. The plugin API reference is at docs.bext.dev/plugins/overview.