WASM Plugins

The WASM tier runs plugins as WebAssembly modules inside the wasmtime runtime. It provides near-native performance with strong memory isolation and deterministic resource limits via fuel budgets. This is a Pro feature.

How It Works

1. You write your plugin in Rust (or Go/C) and compile it to a .wasm module targeting wasm32-wasi. 2. bext loads the module via wasmtime at server start. 3. Each lifecycle call consumes "fuel" -- a wasmtime abstraction that limits computation. When fuel runs out, the call is terminated. 4. Host functions under the "bext" import module provide storage, HTTP, metrics, and queue access.

Fuel Budgets

Each lifecycle hook has a default fuel budget. One unit of fuel roughly corresponds to one WASM instruction.

Hook Default Fuel Approximate Wall Time
on_server_start 500,000,000 ~500 ms
on_request_complete 100,000,000 ~100 ms
on_cache_write 50,000,000 ~50 ms
on_cache_invalidate 50,000,000 ~50 ms
on_reload 200,000,000 ~200 ms
cleanup 100,000,000 ~100 ms

Override budgets in bext.config.toml:

[plugins.my-wasm-plugin]
sandbox = "wasm"
path = "./plugins/my-plugin.wasm"

[plugins.my-wasm-plugin.fuel]
on_server_start = 1_000_000_000
on_request_complete = 200_000_000

Host Function Imports

WASM plugins access host capabilities through functions imported from the "bext" module. All complex types are passed as JSON-encoded strings via (ptr, len) pairs.

Available Host Functions

Function Signature Description
bext.log (level_ptr, level_len, msg_ptr, msg_len) Structured logging
bext.storage_get (key_ptr, key_len) -> (ptr, len) Read from scoped KV store
bext.storage_set (key_ptr, key_len, val_ptr, val_len) -> i32 Write to KV store
bext.storage_delete (key_ptr, key_len) -> i32 Delete KV entry
bext.http_fetch (req_ptr, req_len) -> (ptr, len) HTTP client (rate-limited)
bext.get_config (key_ptr, key_len) -> (ptr, len) Read plugin config value
bext.emit_metric (name_ptr, name_len, value: f64, tags_ptr, tags_len) Emit metric

The http_fetch request and response use this JSON format:

// Request
{ "url": "https://api.example.com/data", "method": "POST",
  "headers": [["Content-Type", "application/json"]], "body": "{}" }

// Response
{ "status": 200,
  "headers": [["content-type", "application/json"]],
  "body": "{\"result\": \"ok\"}" }

Sandbox Permissions

[plugins.my-wasm-plugin.permissions]
allowed_urls = ["https://api.example.com/*"]
storage_quota_kb = 1024
max_fetch_per_minute = 30
kv_namespaces = ["analytics"]
queue_names = ["events"]

- allowed_urls — glob patterns for outbound HTTP. Empty = no network access.

- storage_quota_kb — maximum KV storage per plugin (default: 1024 KB).

- max_fetch_per_minute — token-bucket rate limit for HTTP fetches (default: 30).

- kv_namespaces — shared KV namespaces the plugin can access. Empty = plugin-scoped only.

- queue_names — message queues the plugin can publish to or consume from.

Building a Rust WASM Plugin

1. Create the project

cargo new --lib my-analytics-plugin
cd my-analytics-plugin

2. Set up Cargo.toml

[package]
name = "my-analytics-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
bext-plugin-api = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3. Implement the LifecyclePlugin trait

use bext_plugin_api::lifecycle::LifecyclePlugin;
use serde::Deserialize;

#[derive(Deserialize)]
struct Config {
    endpoint: String,
    batch_size: usize,
}

struct AnalyticsPlugin {
    config: Option,
    buffer: Vec,
}

impl LifecyclePlugin for AnalyticsPlugin {
    fn name(&self) -> &str {
        "my-analytics"
    }

    fn priority(&self) -> u32 {
        1000
    }

    fn on_server_start(&self, config_json: &str) -> Result<(), String> {
        let config: Config = serde_json::from_str(config_json)
            .map_err(|e| format!("parse config: {e}"))?;
        // Store config for later use
        bext::log("info", &format!("Analytics endpoint: {}", config.endpoint));
        Ok(())
    }

    fn on_request_complete(&self, event_json: &str) -> Result<(), String> {
        // Buffer events, flush when batch is full
        bext::storage_set("pending", event_json)?;
        bext::emit_metric("analytics_events", 1.0, &[("source", "wasm")]);
        Ok(())
    }

    fn cleanup(&self) -> Result<(), String> {
        bext::log("info", "Flushing remaining analytics events");
        Ok(())
    }
}

4. Compile to WASM

cargo build --target wasm32-wasi --release
cp target/wasm32-wasi/release/my_analytics_plugin.wasm ./plugin.wasm

5. Configure in bext

[plugins.my-analytics]
sandbox = "wasm"
path = "./plugins/my-analytics/plugin.wasm"

[plugins.my-analytics.config]
endpoint = "https://analytics.example.com/ingest"
batch_size = 100

[plugins.my-analytics.permissions]
allowed_urls = ["https://analytics.example.com/*"]
max_fetch_per_minute = 60

Debugging WASM Plugins

Enable WASI logging to see stdout/stderr from your WASM module:

BEXT_PLUGIN_LOG=debug bext dev

wasmtime traps (out-of-fuel, memory violations, unreachable instructions) are logged with a full stack trace when debug logging is enabled.

To profile fuel consumption:

bext plugin stats my-analytics

This reports per-hook fuel usage, call counts, and average execution time.

Go and C Plugins

Any language that compiles to wasm32-wasi works. For Go, use TinyGo:

tinygo build -o plugin.wasm -target=wasi ./main.go

For C, use wasi-sdk:

$WASI_SDK_PATH/bin/clang --sysroot=$WASI_SDK_PATH/share/wasi-sysroot \
  -o plugin.wasm plugin.c

The host function ABI is the same regardless of source language -- JSON strings passed via (ptr, len) pairs.