camel-component-wasm
WASM plugin component for rust-camel — loads and executes WASM modules as route processors using the Component Model.
Features
- WASM Component Model: Loads WASM modules compiled for
wasm32-wasip2target - Wasmtime v31: Latest runtime with async support and component-model features
- Host Functions:
camel_call(),get_property(),set_property(),host_store(),host_load()for guest-host communication - URI-Based Routing:
wasm:path/to/module.wasmformat for easy integration - Path Validation: Prevents directory traversal and escapes from project root
- Recursion Guard: Blocks nested WASM calls to prevent infinite loops
- Tower Service: Implements
Service<Exchange>for async processing - Exchange Properties: Per-request properties accessible from WASM via host functions
- Persistent State:
host_store/host_loadfor per-endpoint state that survives acrossprocess()calls - Production Hardening: Epoch-based timeouts, memory limits, structured trap classification, automatic recovery
Installation
Add to Cargo.toml (workspace dependency):
[]
= true
URI Format
wasm:path/to/module.wasm[?timeout=<secs>&max-memory=<bytes>]
- Must be relative path (no leading
/) - No
..components allowed - Resolved against configured base directory
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
timeout |
u64 (seconds) |
30 |
Max wall-clock time per guest call. Enforced via epoch interruption. |
max-memory |
u64 (bytes) |
52428800 (50 MB) |
Max linear memory the guest can allocate. |
Zero or invalid values are silently ignored and the default is used.
Examples
wasm:plugins/transform.wasm
wasm:plugins/transform.wasm?timeout=5
wasm:plugins/transform.wasm?timeout=10&max-memory=10485760
Host Functions
WASM plugins can call these host functions from guest code:
camel_call(uri: String, payload: String) -> Result<String>
Calls another endpoint from within WASM.
// Guest-side (WASM)
use Host;
async
get_property(key: String) -> Option<String>
Retrieves an exchange property by key.
let user_id = get_property.await?;
set_property(key: String, value: String)
Sets an exchange property. Value can be JSON string for structured data.
set_property.await;
set_property.await;
host_store(key: String, value: String) -> Result<()>
Stores a key-value pair that persists across process() calls for this route endpoint.
use state_helpers;
// Store config loaded in init()
store?;
// Store structured data as JSON
store_json?;
host_load(key: String) -> Result<Option<String>>
Loads a previously stored value. Returns None if the key has not been stored.
// Load a string value
let api_key = load?;
// Load and deserialize JSON
let config: = load_json?;
Scope: State is scoped per route endpoint. Different routes using the same
.wasmfile maintain independent state stores.
Usage
Registration
use WasmBundle;
use CamelContext;
async
Route with WASM Processor
use RouteBuilder;
// Route that processes data through a WASM module
ctx.add_route.await?;
WASM Plugin Example
Build your plugin with wasm32-wasip2 target:
// src/main.rs (guest plugin)
use ;
async
async
Build:
Chaining WASM Plugins
from
.to
.to
.to
.to
.build?;
Security
Path Validation
- Absolute paths are rejected
- Paths containing
..are rejected - Canonical path must start with base directory
- Prevents directory traversal attacks
Recursion Guard
- WASM plugins cannot call other WASM plugins via
camel_call() - Prevents infinite recursion and stack overflow
- Returns error:
recursive wasm calls not supported
Production Configuration
Phase 4 hardening adds epoch-based timeouts, memory limits, and structured trap classification to every plugin call.
Timeout enforcement
Every guest call (init and process) sets an epoch deadline before invocation. A background thread (EpochTicker) increments the wasmtime engine epoch every 10 ms. If the deadline is exceeded, the call is interrupted and returns WasmError::Timeout.
// 5-second timeout
let uri = "wasm:plugins/slow.wasm?timeout=5";
Memory limits
StoreLimits is installed in every Store. If the guest exceeds max-memory, the next allocation fails and returns WasmError::OutOfMemory.
// 10 MB limit
let uri = "wasm:plugins/heavy.wasm?max-memory=10485760";
Configuring Bean and AuthorizationPolicy plugins via Camel.toml
Processor plugins are configured via the wasm: URI query string (above). For Bean and AuthorizationPolicy plugins, the same knobs are exposed through a [limits] block in Camel.toml:
# Bean plugin
[]
= "my-bean"
[]
= 600 # optional, defaults to 30
= 4294967296 # optional, defaults to 52428800 (50 MiB)
= 4 # optional, defaults to 4 (bean: not enforced today)
# AuthorizationPolicy plugin (WASM provider)
[]
= "wasm"
= "plugins/authz.wasm"
[]
= 5
= 10485760
All three fields are optional. None means "use the runtime default" — no silent fallback lie (per ADR-0011). The defaults are applied explicitly in WasmConfig::from_limits, the single source of truth.
For SecurityPolicy, ADR-0014 documents why no Camel.toml path is exposed (no production callers today). The runtime still honours the default 50 MiB cap through the shared WasmRuntime::create_host_state.
See docs/adr/0014-wasm-plugin-config-unification.md for the full design rationale.
Error variants
| Variant | When raised |
|---|---|
WasmError::Timeout { plugin, timeout_secs } |
Epoch deadline exceeded |
WasmError::OutOfMemory { plugin, max_memory_bytes } |
Guest exceeded memory limit |
WasmError::Trap { plugin, reason } |
Guest hit unreachable/stack-overflow/other trap |
WasmError::GuestPanic(msg) |
Guest panicked with a message |
WasmError::Unhealthy(msg) |
Plugin failed health check |
Recovery
After a Timeout, Trap, or OutOfMemory, the plugin runtime is automatically reset on the next call. No manual intervention required.
Architecture
┌─────────────────┐
│ WasmComponent │
│ (scheme: wasm) │
└────────┬────────┘
│ creates_endpoint()
▼
┌─────────────────┐
│ WasmEndpoint │
│ (URI resolver) │
└────────┬────────┘
│ create_producer()
▼
┌─────────────────┐
│ WasmProducer │
│ (Tower Service)│
└────────┬────────┘
│ poll_ready() -> call()
▼
┌─────────────────┐
│ WasmRuntime │
│ (Wasmtime) │
└─────────────────┘
│ call_init_once() / call_process()
▼
┌─────────────────┐
│ WasmHostState │
│ (registry, │
│ properties, │
│ state_store, │
│ call_depth) │
└─────────────────┘
- WasmComponent: Component trait implementation, URI scheme
wasm:, path validation - WasmEndpoint: Resolves URI to WASM module path, creates producer
- WasmProducer: Tower Service wrapping WasmRuntime, lazy initialization, error handling
- WasmRuntime: Wasmtime engine, linker, component instantiation
- WasmHostState: Per-request state with registry, properties, call depth guard
- WasmSourceConsumer:
Consumerimpl for thesourceworld; bridges the guest's blocking run loop to the async pipeline via bounded channels (see WASM Source Components)
Host Function Internals
The WasmHostState maintains per-invocation state:
Each request gets a new WasmHostState with:
- Exchange properties copied from the incoming
Exchange call_depthreset to 0- Fresh WASI context with stderr inheritance
Testing
Unit tests verify path validation, recursion guard, host functions, state persistence, hardening (epoch timeout, memory limits, trap recovery), and performance benchmarks:
# 81 tests: 50 unit + 10 hardening + 14 integration + 6 state + 1 perf
Integration tests require a compiled WASM module:
# Build test plugin
# Run integration tests
Guest SDK
See crates/camel-wasm-sdk/README.md for plugin development:
#[plugin]macro for exported functionsExchangeandBodytypes for data accessHosttrait for calling host functions
Bean Support
The WASM component also supports bean plugins — multi-method WASM components that expose several callable methods from a single module.
WasmBean Adapter
WasmBean is the host-side adapter that loads a WASM bean module and dispatches method calls to the correct guest function. It uses the bean WIT world (distinct from the processor world) to communicate with the guest.
Configuration
Register beans in Camel.toml:
[]
= "my-auth-bean"
Each bean entry creates an isolated WASM instance. Methods are invoked by name from YAML DSL or Rust routes:
routes:
- id: "auth-route"
from: "direct:auth"
steps:
- bean:
name: "auth"
method: "validate"
Building Bean Plugins
Use the SDK's BeanPlugin trait and export_bean! macro:
use ;
;
export_bean!;
Security Policy Support
The WASM component can serve as a security policy backend, delegating authorization decisions to a guest module. Two host types are provided, both backed by the shared WasmPluginContext.
Exchange-Level: WasmSecurityPolicy
Implements the SecurityPolicy trait. Called during route processing with the full Exchange (including camel.auth.* properties populated by the authentication layer). The guest module's evaluate() function returns:
Ok(None)-- access granted, the exchange continuesOk(Some(reason))-- access denied with a reason stringErr(...)-- processing error, propagated asCamelError
use WasmSecurityPolicy;
let policy = new.await?;
Permission-Level: WasmAuthorizationPolicyEvaluator
Implements the PermissionEvaluator trait. Called by the PermissionEvaluatorRegistry with a PermissionRequest (principal, resource, action, scopes). The host builds a synthetic Exchange from the request and delegates to the same guest evaluate() function. Returns PermissionDecision::Granted or PermissionDecision::Denied { reason }.
Registered as a permission provider in Camel.toml:
[]
= "wasm"
= "plugins/rbac-policy.wasm"
[]
= "viewer"
Shared Context: WasmPluginContext
Both WasmSecurityPolicy and WasmAuthorizationPolicyEvaluator are thin wrappers around WasmPluginContext, which owns:
EngineandLinker-- shared WASM runtimeComponent-- the compiled guest moduleRegistry-- for host function callbacksStateStore-- persistent key-value stateEpochTicker-- epoch-based timeout enforcement
Each call to evaluate() creates a fresh Store from this shared context, so concurrent evaluations are isolated.
Authorization Policy WIT World
Guest modules implementing security policies must target the authorization-policy world defined in wit/camel-plugin.wit. This world imports the same host functions as the plugin world and exports two functions:
world authorization-policy {
import host;
use types.{wasm-exchange, wasm-error};
/// Evaluate the exchange and return an authorization decision.
/// None = Granted, Some(reason) = Denied.
export evaluate: func(exchange: wasm-exchange) -> result<option<string>, wasm-error>;
/// Initialization hook with config from registration.
export init: func(config: list<tuple<string, string>>) -> result<_, string>;
}
Key differences from the plugin world:
evaluatereplacesprocess-- returnsoption<string>(deny reason) instead of a full exchangeinitreceives key-value config from the provider registration inCamel.toml- Guest reads auth context via
get-property("camel.auth.roles"),get-property("camel.auth.principal"), etc.
Route Example
routes:
- id: "secured-api"
from: "http:0.0.0.0:8080/api"
security_policy:
wasm: "corp-auth"
steps:
- to: "log:secured"
The security_policy: wasm form references a WasmSecurityPolicy registered in the SecurityPolicyRegistry. Policies are registered from Camel.toml:
[]
= "fixtures/role-check.wasm"
[]
= 30
= 52428800
[]
= "ldap://corp"
The guest module's init() function receives the [<name>.config] pairs as sorted (String, String) arguments at instantiation time.
Migration — Previously the YAML form accepted a
config:block per-route, which was silently dropped. As of ADR-0014 §4 closure (bd rc-0te), per-routeconfigis rejected with a hard error. Moveconfig:keys to[security.policies.wasm.<name>.config]in Camel.toml.
WASM Source Components
A WASM source is a 3rd-party component that acts as an inbound source — a webhook receiver or HTTP listener. Unlike a processor (host-driven process() calls), the source guest owns its consumption loop: it declares what it needs up front, the host grants an http-listener resource, and the guest then drains requests and pushes exchanges into the pipeline.
The host adapter is WasmSourceConsumer, which implements the Consumer trait and bridges the guest's synchronous run() loop to the async pipeline via capacity-1 tokio channels.
URI Format
wasm:<module>[?bind=<addr>&path=<path>&timeout=<secs>&max-memory=<bytes>]
In addition to the standard processor query parameters, sources accept:
| Parameter | Type | Default | Description |
|---|---|---|---|
bind |
socket address | 0.0.0.0:8080 |
Address the host-granted HTTP listener binds. Surfaced as a source-config key to the guest. |
path |
string | (all paths) | URL path filter the listener accepts (e.g. /webhook). |
bind and path are passed into the guest's configure() as (key, value) pairs; the guest reflects them back inside its source-plan. The TCP listener is bound synchronously during start() so a bind failure (port in use, permission denied) surfaces as a start error rather than a silent background warning.
wasm:wasm-source-webhook-guest.wasm?bind=0.0.0.0:8080&path=/webhook
wasm:plugins/my-source.wasm?bind=127.0.0.1:9090&timeout=30
Lifecycle: resource negotiation
The source world uses a negotiate-then-run handshake:
configure(config) -> source-plan— the host passes the URI query params (plus any registered config). The guest returns asource-plandeclaring the capability it needs and its concurrency model.- Host grants the capability — the plan must request exactly one
http-listenercapability. The host creates anhttp-listenerresource handle and binds the TCP listener. run(listener) -> result— the host hands the listener to the guest, which owns a blocking loop calling the host imports below until cancelled or stopped.
Host capabilities (guest imports)
| Function | Signature | Effect |
|---|---|---|
accept-http |
(listener: borrow<http-listener>) -> result<option<http-request>, wasm-error> |
Blocking; returns the next inbound HTTP request, or none when cancelled (channel closed). |
submit-exchange |
(exchange: wasm-exchange) -> result<submit-outcome, wasm-error> |
Pushes an exchange into the pipeline. Blocks on backpressure; returns stopped if the host is shutting down. |
is-cancelled |
() -> bool |
True when the host has cancelled the run loop (e.g. route shutdown). |
The http-listener resource itself is host-owned and has no guest-callable methods — it is merely the capability handle passed to accept-http.
Concurrency model
source-plan.concurrency declares how the guest wants to be drained:
sequential— supported. The host drains the guest strictly one request at a time through capacity-1 channels.concurrent(max)— not implemented. Rejected atstart()with an explicit error rather than silently degraded, because degradingconcurrent(N)to sequential would violate the guest's declared contract.
Limitations
- Sequential mode only —
concurrent(N)plans are rejected (see above). - HTTP transport only — the single capability granted is
http-listener; there is no Kafka/queue/gRPC source yet. - Synchronous bindings (WASI 0.2) — the guest targets
wasm32-wasip2and its host imports block on tokio channels, so the guestrun()loop runs on a dedicated OS thread (spawn_blocking), off the async runtime.
For a working end-to-end example, see examples/wasm-source-webhook/.
Documentation
License
This project is licensed under the same license as rust-camel.
Contributing
See the main repository for contribution guidelines.