WASM Middleware
Conduit can load WebAssembly plugins as middleware. Plugins run for every request and can inspect or modify headers, short-circuit with a custom response, or redirect the client.
Requires
cargo build --features wasm
Table of Contents
- Configuration
- Plugin ABI
- Memory conventions
- Supported languages
- Examples
- Response phase — on_response
- Building plugins
- Execution model
- Error handling
- Comparison with Rhai
Configuration
middleware:
- type: wasm
path: ./plugins/auth-check.wasm
# Multiple plugins run in order — first abort wins
- type: wasm
path: ./plugins/rate-check.wasm
# Pass a JSON config object to the plugin
- type: wasm
path: ./plugins/validator.wasm
config:
max_size_kb: 512
allowed_origins: ["https://app.example.com"]
{
"middleware": [
{ "type": "wasm", "path": "./plugins/auth-check.wasm" },
{
"type": "wasm",
"path": "./plugins/validator.wasm",
"config": { "max_size_kb": 512 }
}
]
}
Rhai and WASM entries can be mixed freely — they run in declaration order:
middleware:
- type: script
path: ./scripts/ip-check.rhai
- type: wasm
path: ./plugins/jwt-validate.wasm
- type: script
path: ./scripts/log-request.rhai
Plugin ABI
Plugins communicate with Conduit through a set of host functions imported
from the "conduit" namespace, and a single exported function that
Conduit calls for each request.
Required exports
| Export | Signature | Required | Description |
|---|---|---|---|
on_request |
() -> i32 |
Yes | Called for every request. Return 0 to continue, 1 (or any non-zero) to abort |
memory |
linear memory | Yes | Must be exported — all string data passes through it |
on_response |
(i32) -> i32 |
No | Called after upstream responds. Parameter = upstream status. Return value is ignored — always continues. See Response phase |
on_requestis always required, even for response-only plugins. The function body can be empty (return 0) if you only needon_response.
Host functions — read request
All read functions write their result into a caller-supplied buffer and return
the number of bytes written. If the buffer is too small, the result is
truncated (no error). If the value does not exist (e.g. header not found),
-1 is returned.
| Function | Description |
|---|---|
conduit_get_method(buf: i32, buf_len: i32) -> i32 |
HTTP method ("GET", "POST", …) |
conduit_get_path(buf: i32, buf_len: i32) -> i32 |
Request path, e.g. "/api/users" |
conduit_get_query(buf: i32, buf_len: i32) -> i32 |
Raw query string; empty when absent |
conduit_get_uri(buf: i32, buf_len: i32) -> i32 |
Full URI: path + "?" + query |
conduit_get_client_ip(buf: i32, buf_len: i32) -> i32 |
Remote client IP address |
conduit_get_request_id(buf: i32, buf_len: i32) -> i32 |
X-Request-ID header value |
conduit_get_header(name_ptr: i32, name_len: i32, buf: i32, buf_len: i32) -> i32 |
Named header value; -1 if absent. Look-up is case-insensitive |
conduit_get_header_count() -> i32 |
Number of request headers |
conduit_get_header_names(buf: i32, buf_len: i32) -> i32 |
All header names, newline-separated |
conduit_get_plugin_config(buf: i32, buf_len: i32) -> i32 |
JSON bytes from middleware[].config; empty when not set |
Host functions — mutate request
Header mutations are collected during the plugin call and applied to the
upstream request after on_request returns 0 (continue). They have no
effect if the plugin aborts.
| Function | Description |
|---|---|
conduit_set_request_header(name_ptr: i32, name_len: i32, val_ptr: i32, val_len: i32) |
Add or overwrite a request header |
conduit_remove_request_header(name_ptr: i32, name_len: i32) |
Remove a request header |
Host functions — abort response
Call these before returning 1 from on_request. They have no effect
when the plugin continues (return 0).
| Function | Description |
|---|---|
conduit_set_response_status(status: i32) |
HTTP status code (clamped to 100–999; default: 500) |
conduit_set_response_header(name_ptr: i32, name_len: i32, val_ptr: i32, val_len: i32) |
Add a response header |
conduit_set_response_body(body_ptr: i32, body_len: i32) |
Set the response body (bytes, not required to be UTF-8) |
conduit_abort_with_redirect(url_ptr: i32, url_len: i32) |
Shortcut: sets status 302 + Location header + body "Redirecting...". Still return 1. |
Host functions — logging
| Function | Description |
|---|---|
conduit_log(level: i32, msg_ptr: i32, msg_len: i32) |
Write to the Conduit log. Levels: 0=trace 1=debug 2=info 3=warn 4=error |
Memory conventions
All string data passes through the plugin’s linear memory:
- To read from a host function: allocate a buffer in WASM memory, pass
(ptr, len)to the function, then read up to the returned byte count. - To write to a host function: write the string into WASM memory, then
pass
(ptr, len)to the function. - Conduit never retains pointers after the host function returns — no dangling-pointer risk.
- The host function return value is the number of bytes written (or
-1for missing headers). A return less thanbuf_lenmeans the full value fit.
Supported languages
Any language that compiles to wasm32-unknown-unknown (no OS dependencies) works.
| Language | Toolchain | Notes |
|---|---|---|
| Rust | cargo build --target wasm32-unknown-unknown |
Best ecosystem for WASM; zero-cost abstractions |
| C / C++ | clang --target=wasm32 -nostdlib |
Low-level, minimal binary size |
| Go | TinyGo tinygo build -target=wasm-unknown |
Full Go syntax; TinyGo required. Use wasm-unknown (freestanding) target — see note below |
| AssemblyScript | asc (AssemblyScript compiler) |
TypeScript-like syntax; designed for WASM |
| Zig | zig build-lib -target wasm32-freestanding |
Systems language with excellent WASM support |
TinyGo target note: Conduit only registers imports under the
"conduit"namespace — it does not implementwasi_snapshot_preview1.
TinyGo target Imports Works with Conduit? -target=wasm-unknownOnly explicit //go:wasmimport✅ Use this -target=wasi/-target=wasip1wasi_snapshot_preview1(fd_write, etc.)❌ Fails at instantiation -target=wasmenv/gojs (browser-focused) ❌ Not freestanding Use
-target=wasm-unknownand declare all host functions with//go:wasmimport conduit <funcname>— no WASI runtime needed.
Examples
Every plugin is loaded via conduit.yaml (or conduit.json). The general
pattern — place the middleware array alongside your proxy/static config:
# conduit.yaml
middleware:
- type: wasm
path: ./plugins/my-plugin.wasm # path relative to working directory
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./plugins/my-plugin.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Plugins run in order for every request. Multiple plugins and Rhai scripts can be mixed freely. See configuration.md — WASM middleware for all config options.
Minimal plugin (WAT)
The smallest possible plugin — always passes through:
(module
(memory (export "memory") 1)
(func (export "on_request") (result i32)
i32.const 0 ;; 0 = continue
)
)
Compile with wat2wasm or wasm-tools:
# wat2wasm (from wabt — https://github.com/WebAssembly/wabt)
wat2wasm minimal.wat -o minimal.wasm
# wasm-tools (modern alternative — cargo install wasm-tools)
wasm-tools parse minimal.wat -o minimal.wasm
conduit.yaml:
middleware:
- type: wasm
path: ./minimal.wasm
proxy:
/: "http://backend:4000"
Header check in Rust
Returns 401 when X-API-Key is missing or wrong.
Project structure:
api-key-check/
├── Cargo.toml
└── src/lib.rs
# Cargo.toml
[package]
name = "api-key-check" # ← determines the output filename
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
// src/lib.rs
unsafe extern "C" {
fn conduit_get_header(name_ptr: i32, name_len: i32, buf: i32, buf_len: i32) -> i32;
fn conduit_set_response_status(status: i32);
fn conduit_set_response_body(body_ptr: i32, body_len: i32);
}
static mut BUF: [u8; 256] = [0u8; 256];
#[no_mangle]
pub extern "C" fn on_request() -> i32 {
let key_name = b"x-api-key";
let n = unsafe {
conduit_get_header(
key_name.as_ptr() as i32, key_name.len() as i32,
BUF.as_ptr() as i32, BUF.len() as i32,
)
};
if n < 0 {
reject(401, b"missing API key");
return 1;
}
let value = unsafe { &BUF[..n as usize] };
// NOTE: For production, use constant-time comparison (e.g. subtle::ConstantTimeEq)
// to prevent timing attacks. The direct != leaks key prefix information.
if value != b"my-secret" {
reject(403, b"invalid API key");
return 1;
}
0
}
fn reject(status: i32, msg: &[u8]) {
unsafe {
conduit_set_response_status(status);
conduit_set_response_body(msg.as_ptr() as i32, msg.len() as i32);
}
}
Build:
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/api_key_check.wasm
# (Cargo replaces hyphens with underscores in the filename)
cp target/wasm32-unknown-unknown/release/api_key_check.wasm ./plugins/
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./plugins/api_key_check.wasm # the copied .wasm file
proxy:
/api: "http://backend:4000"
healthCheck: true
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./plugins/api_key_check.wasm" }],
"proxy": { "/api": "http://backend:4000" },
"healthCheck": true
}
Inject a request header
Adds X-Plugin-Version: 1.0 to every upstream request.
Project structure:
inject-header/
├── Cargo.toml
└── src/lib.rs
# Cargo.toml
[package]
name = "inject-header"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
// src/lib.rs
unsafe extern "C" {
fn conduit_set_request_header(name_ptr: i32, name_len: i32, val_ptr: i32, val_len: i32);
}
#[no_mangle]
pub extern "C" fn on_request() -> i32 {
let name = b"x-plugin-version";
let value = b"1.0";
unsafe {
conduit_set_request_header(
name.as_ptr() as i32, name.len() as i32,
value.as_ptr() as i32, value.len() as i32,
);
}
0 // continue — header mutation is applied before forwarding
}
Build:
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/inject_header.wasm
cp target/wasm32-unknown-unknown/release/inject_header.wasm ./plugins/
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./plugins/inject_header.wasm
proxy:
/: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./plugins/inject_header.wasm" }],
"proxy": { "/": "http://backend:4000" }
}
Redirect old paths
Sends a 302 redirect from /old-api/ to /api/.
Project structure:
path-redirect/
├── Cargo.toml
└── src/lib.rs
# Cargo.toml
[package]
name = "path-redirect"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
// src/lib.rs
unsafe extern "C" {
fn conduit_get_path(buf: i32, buf_len: i32) -> i32;
fn conduit_abort_with_redirect(url_ptr: i32, url_len: i32);
}
static mut PATH_BUF: [u8; 512] = [0u8; 512];
#[no_mangle]
pub extern "C" fn on_request() -> i32 {
let n = unsafe {
conduit_get_path(PATH_BUF.as_ptr() as i32, PATH_BUF.len() as i32)
};
if n <= 0 { return 0; }
let path = unsafe { &PATH_BUF[..n as usize] };
if path.starts_with(b"/old-api/") {
let new_path = b"/api/";
unsafe {
conduit_abort_with_redirect(new_path.as_ptr() as i32, new_path.len() as i32);
}
return 1; // abort with 302 redirect
}
0
}
Build:
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/path_redirect.wasm
cp target/wasm32-unknown-unknown/release/path_redirect.wasm ./plugins/
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./plugins/path_redirect.wasm # runs before the proxy
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./plugins/path_redirect.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Header check in C
Project structure:
api-key-check-c/
└── plugin.c
// plugin.c
extern int conduit_get_header(
const char *name, int name_len,
char *buf, int buf_len);
extern void conduit_set_response_status(int status);
extern void conduit_set_response_body(const char *body, int body_len);
static char buf[256];
__attribute__((export_name("on_request")))
int on_request(void) {
const char *key_name = "x-api-key";
int n = conduit_get_header(key_name, 9, buf, sizeof(buf));
if (n < 0) {
conduit_set_response_status(401);
const char *msg = "missing API key";
conduit_set_response_body(msg, 15);
return 1;
}
const char *expected = "my-secret";
if (n != 9) { conduit_set_response_status(403); return 1; }
for (int i = 0; i < 9; i++) {
if (buf[i] != expected[i]) { conduit_set_response_status(403); return 1; } // NOTE: use XOR-fold for constant-time
}
return 0;
}
Build (output filename is explicit in -o):
clang --target=wasm32 -nostdlib -Wl,--no-entry \
-Wl,--export=on_request -Wl,--export=memory \
-o api_key_check.wasm plugin.c
# Output: api_key_check.wasm ← you choose the name in -o
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./api_key_check.wasm # the -o name from build step
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./api_key_check.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Header check in Go (TinyGo)
Project structure:
api-key-check-go/
└── plugin.go
// plugin.go
package main
import "unsafe"
//go:wasmimport conduit conduit_get_header
func conduitGetHeader(namePtr, nameLen, bufPtr, bufLen uint32) int32
//go:wasmimport conduit conduit_set_response_status
func conduitSetResponseStatus(status int32)
//export on_request
func onRequest() int32 {
name := "x-api-key"
buf := make([]byte, 256)
n := conduitGetHeader(
uint32(uintptr(unsafe.Pointer(&[]byte(name)[0]))),
uint32(len(name)),
uint32(uintptr(unsafe.Pointer(&buf[0]))),
uint32(len(buf)),
)
if n < 0 {
conduitSetResponseStatus(401)
return 1
}
// NOTE: For production, use crypto/subtle.ConstantTimeCompare to prevent timing attacks.
if string(buf[:n]) != "my-secret" {
conduitSetResponseStatus(403)
return 1
}
return 0
}
func main() {}
Build (output filename is explicit in -o):
# Install TinyGo: https://tinygo.org/getting-started/install/
tinygo build -o api_key_check.wasm -target=wasm-unknown ./plugin.go
# Output: api_key_check.wasm ← you choose the name in -o
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./api_key_check.wasm # the -o name from build step
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./api_key_check.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Header check in AssemblyScript
AssemblyScript compiles TypeScript-like syntax directly to WASM — no Rust or C toolchain needed.
Project structure:
api-key-check-as/
├── package.json
└── plugin.ts
# package.json — install AssemblyScript compiler
npm init -y
npm install --save-dev assemblyscript
npx asinit .
// plugin.ts
@external("conduit", "conduit_get_header")
declare function conduit_get_header(
namePtr: i32, nameLen: i32,
buf: i32, bufLen: i32
): i32;
@external("conduit", "conduit_set_response_status")
declare function conduit_set_response_status(status: i32): void;
@external("conduit", "conduit_set_response_body")
declare function conduit_set_response_body(bodyPtr: i32, bodyLen: i32): void;
const BUF_SIZE: i32 = 256;
let buf = new ArrayBuffer(BUF_SIZE);
export function on_request(): i32 {
const keyName = String.UTF8.encode("x-api-key");
const bufPtr = changetype<i32>(buf);
const n = conduit_get_header(
changetype<i32>(keyName), keyName.byteLength,
bufPtr, BUF_SIZE
);
if (n < 0) {
const msg = String.UTF8.encode("missing API key");
conduit_set_response_status(401);
conduit_set_response_body(changetype<i32>(msg), msg.byteLength);
return 1;
}
const received = String.UTF8.decodeUnsafe(bufPtr, n);
// NOTE: For production, use constant-time comparison (XOR-fold) to prevent timing attacks.
if (received != "my-secret") {
conduit_set_response_status(403);
return 1;
}
return 0;
}
Build:
npx asc plugin.ts --target release --outFile api_key_check.wasm \
--exportRuntime --exportMemory --use abort=
# Output: api_key_check.wasm
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./api_key_check.wasm
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./api_key_check.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Using the plugin config field
The config object from conduit.yaml is passed to the plugin as a JSON
string via conduit_get_plugin_config. The plugin must parse it itself.
conduit.yaml / conduit.json:
# conduit.yaml
middleware:
- type: wasm
path: ./plugins/validator.wasm
config:
allowed_key: "secret-abc"
max_body_kb: 512
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [
{
"type": "wasm",
"path": "./plugins/validator.wasm",
"config": { "allowed_key": "secret-abc", "max_body_kb": 512 }
}
],
"proxy": { "/api": "http://backend:4000" }
}
Reading the config in Rust:
unsafe extern "C" {
fn conduit_get_plugin_config(buf: i32, buf_len: i32) -> i32;
fn conduit_get_header(name_ptr: i32, name_len: i32, buf: i32, buf_len: i32) -> i32;
fn conduit_set_response_status(status: i32);
}
static mut CFG_BUF: [u8; 1024] = [0u8; 1024];
static mut HDR_BUF: [u8; 256] = [0u8; 256];
#[no_mangle]
pub extern "C" fn on_request() -> i32 {
// Read config JSON, e.g. {"allowed_key":"secret-abc"}
let cfg_len = unsafe {
conduit_get_plugin_config(CFG_BUF.as_ptr() as i32, CFG_BUF.len() as i32)
};
// Parse with a minimal JSON reader or use a no_std JSON crate.
// Example: check for the string "secret-abc" directly in the bytes.
let cfg = unsafe { &CFG_BUF[..cfg_len.max(0) as usize] };
let key_name = b"x-api-key";
let n = unsafe {
conduit_get_header(
key_name.as_ptr() as i32, key_name.len() as i32,
HDR_BUF.as_ptr() as i32, HDR_BUF.len() as i32,
)
};
if n < 0 {
unsafe { conduit_set_response_status(401); }
return 1;
}
let key = unsafe { &HDR_BUF[..n as usize] };
// Simple substring check — use a proper JSON parser in production.
if !cfg.windows(key.len()).any(|w| w == key) {
unsafe { conduit_set_response_status(403); }
return 1;
}
0
}
For a cleaner approach, add a no_std-compatible JSON crate like
miniserde or
serde_json_core.
Response phase — on_response
Plugins can optionally export on_response(status: i32) -> i32 to run after
the upstream responds. The export is optional — if missing, the plugin is
simply not called for the response.
;; WAT — plugin handles both request and response
(module
(memory (export "memory") 1)
(func (export "on_request") (result i32)
i32.const 0 ;; continue
)
(func (export "on_response") (param $status i32) (result i32)
;; status parameter = upstream HTTP status code
i32.const 0 ;; return value is ignored (always continues)
)
)
Response host functions
In on_response, seven host functions are available:
| Function | Description |
|---|---|
conduit_get_response_status() -> i32 |
Upstream HTTP status code |
conduit_get_response_header(name_ptr, name_len, buf, buf_len) -> i32 |
Read upstream response header; -1 if absent |
conduit_set_response_header(name_ptr, name_len, val_ptr, val_len) |
Add/overwrite header on client response |
conduit_remove_response_header(name_ptr, name_len) |
Remove header from client response |
conduit_set_response_body(body_ptr, body_len) |
Replace response body |
conduit_get_plugin_config(buf, buf_len) -> i32 |
Same as request phase |
conduit_log(level, msg_ptr, msg_len) |
Same as request phase |
Request-phase functions (
conduit_get_method,conduit_get_header, etc.) are not available inon_response.
Example in Rust — add header on error
// src/lib.rs
unsafe extern "C" {
fn conduit_get_response_status() -> i32;
fn conduit_set_response_header(name_ptr: i32, name_len: i32, val_ptr: i32, val_len: i32);
fn conduit_set_response_body(body_ptr: i32, body_len: i32);
}
// on_request is still required even if you only need on_response
#[no_mangle]
pub extern "C" fn on_request() -> i32 { 0 }
#[no_mangle]
pub extern "C" fn on_response(_status: i32) -> i32 {
let status = unsafe { conduit_get_response_status() };
if status >= 500 {
// Replace error body with a clean JSON message
let body = b"{\"error\":\"Internal Server Error\"}";
unsafe {
conduit_set_response_body(body.as_ptr() as i32, body.len() as i32);
let ct = b"content-type";
let ctv = b"application/json";
conduit_set_response_header(
ct.as_ptr() as i32, ct.len() as i32,
ctv.as_ptr() as i32, ctv.len() as i32,
);
}
}
0
}
conduit.yaml / conduit.json:
# conduit.yaml — no extra config needed, on_response runs automatically
middleware:
- type: wasm
path: ./plugins/error_handler.wasm
proxy:
/api: "http://backend:4000"
// conduit.json
{
"middleware": [{ "type": "wasm", "path": "./plugins/error_handler.wasm" }],
"proxy": { "/api": "http://backend:4000" }
}
Building plugins
Rust — Cargo.toml
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # required: produces a .wasm shared library
[profile.release]
opt-level = "s" # optimize for size
strip = true # strip debug symbols
Rust — build
# Add the WASM target (once)
rustup target add wasm32-unknown-unknown
# Build
cargo build --target wasm32-unknown-unknown --release
# The output filename = package name with hyphens replaced by underscores:
# name = "my-plugin" → target/.../my_plugin.wasm
# name = "api-check" → target/.../api_check.wasm
ls target/wasm32-unknown-unknown/release/*.wasm
Cargo naming rule: Cargo replaces
-with_in the output.wasmfilename.name = "api-key-check"→api_key_check.wasm. Use this exact name inconduit.yaml path:.
C / C++ — build
# Clang with WASM target (install from llvm.org or via package manager)
clang --target=wasm32 -nostdlib \
-Wl,--no-entry -Wl,--export=on_request -Wl,--export=memory \
-o plugin.wasm plugin.c
Go — build with TinyGo
# Install TinyGo: https://tinygo.org/getting-started/install/
tinygo build -o plugin.wasm -target=wasm-unknown ./plugin.go
AssemblyScript — build
# Install: npm install -g assemblyscript
asc plugin.ts --target release --outFile plugin.wasm \
--exportRuntime --exportMemory
Optimize binary size (optional)
wasm-opt shrinks any .wasm file regardless of source language:
# Install: https://github.com/WebAssembly/binaryen/releases
wasm-opt -Os -o plugin-opt.wasm plugin.wasm
Execution model
- Modules are compiled once (on first request) by Wasmtime’s Cranelift JIT and cached for the lifetime of the process. Hot-reload clears the cache.
- Each request runs in its own Wasmtime Store — no shared mutable state between requests, no global variables visible across calls.
- WASM execution is synchronous and runs in the request-handling thread.
- There is no network or filesystem access from within the WASM sandbox —
only the host functions listed above (17 in the request phase, plus 3
additional response-phase functions available in
on_response). - Fuel limit: 10,000,000 Wasmtime fuel units per invocation (both
on_requestandon_response). Each WASM instruction consumes one unit. A plugin that exceeds the limit is terminated and fails open (request passes through). Typical request-phase plugins use well under 100,000 units.
Error handling
Conduit uses fail-open for WASM: if a plugin fails to load, link, or
execute (trap), the error is logged and the request passes through as if the
plugin returned 0 (continue).
WARN conduit::filter::wasm: WASM plugin error — request passes through (fail-open)
plugin="./plugins/auth-check.wasm"
error="WASM module missing 'on_request' export"
Common causes:
| Error | Cause |
| —– | —– |
| missing 'on_request' export | Plugin does not export on_request |
| missing 'memory' export | Plugin does not export its linear memory |
| trap: unreachable | Plugin panicked (e.g. array out-of-bounds) |
| failed to read file | Plugin path does not exist or is not readable |
Comparison with Rhai
| Feature | Rhai | WASM |
|---|---|---|
| Compile-time feature | --features rhai |
--features wasm |
| Language | Rhai (scripting) | Any language compiling to WASM |
| Mutate request headers | ❌ read-only | ✅ set + remove |
| Read client IP | ❌ | ✅ conduit_get_client_ip |
| Plugin config | ✅ config variable |
✅ conduit_get_plugin_config |
| CPU limit per invocation | 500,000 operations | 10,000,000 fuel units |
| Performance | fast (interpreted) | faster (JIT-compiled) |
| Development speed | fast (no build step) | slower (compile needed) |
| Error isolation | fail-open | fail-open |
| Shared state across requests | ❌ none | ❌ none (new Store per request) |
Use Rhai for simple guards that only read headers and abort — fast to write, no build step.
Use WASM when you need to mutate request headers, read client IP, use plugin config, require better performance, or want to write the logic in Rust/Go/C/AssemblyScript.