Skip to main content

Wasm plugins

A Wasm plugin is a sandboxed, headless DetectionConsumer: after the kernel persists a detection batch it hands it to the plugin (as JSON), which runs in a WebAssembly sandbox with zero ambient authority — no filesystem, network, clock, or randomness — and emits derived events back. Emitted events are namespaced, camera-scoped, capped, and persisted through the kernel's normal event path, so they flow to webhooks + sidecars for free.

It is the specialist tool for lightweight, strongly-sandboxed, in-process rule/transform logic on the ingest hot path. For anything with a UI, multiple languages, or heavy/stateful work, use a sidecar plugin instead — sidecars get a network + a scoped key; a Wasm guest gets neither.

How it fits

Sidecar (Phase B)Wasm plugin (Phase D)
Processseparate (any language)in-process (sandbox)
UIyes (iframe at /m/{id}/)none (headless)
Capabilityscoped API key + networknone — pure compute
Best forapps, UIs, integrationsrules, filters, derived events

The runtime (wasmi, a pure-Rust interpreter) ships behind an off-by-default wasm cargo feature — the default appliance binary never links it. Build the server with --features wasm to enable plugin loading.

The plugin

A guest is a wasm32-unknown-unknown core module. It exports a tiny ABI and may import exactly two host functions (heldar.log, heldar.emit_event) — importing anything else (e.g. WASI) fails to load, so the sandbox is closed by construction. The complete, copy-pasteable template is examples/wasm-plugin; the only part you change is the rule() function:

fn rule(input: &Input) {
let threshold = input.config.get("threshold").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
let persons = input.detections.iter().filter(|d| d.label.as_deref() == Some("person")).count();
if persons > threshold {
emit(&Event {
event_type: "occupancy.high".into(),
severity: "warning".into(),
payload: json!({ "persons": persons }),
});
}
}

The host calls heldar_describe() once at load to read { id, name, version, publisher, description, interested_in }, then heldar_handle(ptr, len) per batch (JSON input written to guest memory). Events the guest passes to emit_event are buffered and persisted after the call as wasm.{plugin_id}.{event_type}, always scoped to the batch's camera (a guest cannot forge events for another camera) with the severity clamped to info/warning/critical.

Build + load

# 1. build the guest to wasm32 (the example)
cd examples/wasm-plugin
cargo build --release --target wasm32-unknown-unknown

# 2. drop it into the plugins directory
cp target/wasm32-unknown-unknown/release/heldar_occupancy_plugin.wasm \
<data>/wasm-plugins/occupancy.wasm

# 3. run the server with the wasm feature
cargo run -p heldar-server --features wasm

Loaded plugins appear in GET /api/v1/modules (mount headless, no nav route) and on the Plugins store with a sandboxed compute treatment. v1 loads at boot; changing plugins means a restart.

Sandbox + limits

Every guest runs with hard bounds, configured via env (read by the plugin host):

EnvDefaultBounds
HELDAR_WASM_ENABLEDtruemaster switch (with the wasm feature on)
HELDAR_WASM_PLUGINS_DIR<data>/wasm-pluginswhere *.wasm are loaded from
HELDAR_WASM_FUEL50000000per-call instruction budget (CPU DoS bound — an infinite loop traps)
HELDAR_WASM_MAX_MEMORY_MB64per-call linear-memory cap
HELDAR_WASM_MAX_TABLE_ELEMENTS100000per-call table-element cap (tables are host RAM, not covered by the memory cap)
HELDAR_WASM_MAX_EVENTS64events a guest may emit per call
HELDAR_WASM_MAX_EVENT_BYTES16384per-event byte cap
HELDAR_WASM_MAX_LOG_CALLS256heldar.log calls per batch (bounds a log flood)
HELDAR_WASM_MAX_FAILURES5consecutive failures before the plugin is auto-disabled

A guest trap, fuel exhaustion, OOM, or panic is isolated — it is logged and never crashes the kernel, and a repeatedly-failing plugin is circuit-broken (disabled + a wasm_plugin_disabled event). Guests run on spawn_blocking so wasm CPU never blocks the async reactor.

Trust + scope

v1 loads plugins from a local, operator-controlled directory (operator-trusted). The kernel never downloads or executes remote .wasm, and there is no per-artifact signing yet — those, the Component Model, WASI, host-provided state, and multi-language SDKs are deliberate non-goals for v1. If you later run untrusted third-party Wasm, the upgrade path is the wasmtime runtime (epoch interruption + a more hardened sandbox) behind the same seam.