Plugin SDK
The Plugin SDK is the developer workflow for writing proxy-wasm plugins: dwara-cli plugin new scaffolds a ready-to-build Rust crate against the proxy-wasm ABI, and the gateway runs the compiled .wasm on the live request path of every default build — there is no cargo feature to enable.
This page is the developer home for that workflow: the quickstart, the hostcall support matrix, resource limits and failure semantics, and troubleshooting. Operators configuring existing plugins should read Proxy-Wasm plugins and Plugin lifecycle instead.
Fifteen-minute quickstart
Every command below is verified against a default build (cargo build -p dwara-bin -p dwara-cli). It mirrors the test-02-proxy-wasm.sh and test-07-plugin-sdk.sh scripts in the demos/08-extensibility/ directory.
1. Scaffold the plugin
dwara-cli plugin new my-plugin
cd my-pluginThis creates Cargo.toml (a cdylib depending on proxy-wasm), src/lib.rs (a minimal filter with the request/response headers callbacks), dwara.yaml (a manifest wiring the plugin into a gateway config), README.md, and .gitignore.
To start from a working example instead of the hello-world filter, pass --template with one of static-auth, header-guard, response-body-redact, or request-tagger — the example's source is scaffolded under your crate name, its host-runnable tests included:
dwara-cli plugin new my-plugin --template static-auth2. Implement your logic
Edit the phase callbacks in src/lib.rs. The scaffold logs the request path; the smallest useful change is stamping a header. In on_http_response_headers:
fn on_http_response_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
self.add_http_response_header("x-my-plugin", "active");
Action::Continue
}A plugin can hook any subset of the four phases (see the phase contract) and can short-circuit a request with send_http_response from any of them.
3. Build the .wasm
The wasm32-wasip1 target must be installed (idempotent):
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1The artifact lands at target/wasm32-wasip1/release/my_plugin.wasm.
4. Load it into the gateway
Reference the .wasm in your gateway config and attach it to a route. The scaffold's dwara.yaml is a complete, valid gateway config — dwara-cli validate dwara.yaml passes as generated (validation does not check that the .wasm exists yet; the path resolves against the gateway process's working directory when it loads). A minimal working config:
listeners:
- name: http
address: 127.0.0.1
port: 8080
protocol: http
routes:
- name: api
service: backend
match:
path: { type: prefix, value: /api }
action: { type: proxy }
plugins:
- my-plugin
services:
- name: backend
upstream: backend-upstream
upstreams:
- name: backend-upstream
load_balancer: round_robin
protocol: http1
endpoints:
- address: 127.0.0.1
port: 9000
plugins:
- name: my-plugin
wasm: ./my-plugin/target/wasm32-wasip1/release/my_plugin.wasm
phases:
- request_headers
- response_headersThe wasm: path is resolved relative to the gateway process's working directory — prefer an absolute path outside throwaway setups.
5. Run and verify
DWARA_CONFIG=dwara.yaml dwara
curl -i http://127.0.0.1:8080/api/helloThe response carries x-my-plugin: active — your plugin ran on the live path. (From a source checkout: DWARA_CONFIG=dwara.yaml cargo run -p dwara-bin.)
How the gateway calls your plugin
The host implements the proxy-wasm HTTP filter subset and the standard module lifecycle. The four phase hook points and where they sit in the request pipeline are diagrammed in Proxy-Wasm plugins. For every request on a route referencing your plugin, the gateway:
- instantiates a fresh module instance (fuel and memory limits from the plugin's
limitsblock); - calls
_start,proxy_on_context_create(1, 0),proxy_on_vm_start, thenproxy_on_configure(SDK modules need this sequence; minimal ABI modules without those exports skip them); - calls
proxy_on_context_create(2, 1)before the first phase callback, then the phase exports for the phases your plugin declares, in pipeline order; - always calls
proxy_on_done/proxy_on_log/proxy_on_deleteat the end of the request, on every exit path (short-circuits and failures included).
The header maps your plugin sees follow the proxy-wasm convention: the request map carries :method and :path (the path including the query string) ahead of the real headers; the response map carries :status. A changed :path, :method, or :authority at request_headers rewrites the FORWARDED request — :path is the final upstream target (see Proxy-Wasm plugins: pseudo-header writes); :status writes are ignored. The write-back applies only the headers the chain changed: headers no plugin touched keep their original bytes, non-UTF-8 (obs-text) values included.
Hostcall support matrix
Full hostcall signatures, buffer/map selector values, and status codes: Hostcall API reference.
Audited against the host implementation (crates/dwara-core/src/wasm/). "Per-instance" state lives inside one request's module instance: it does not survive the request and is not shared across plugins.
HTTP headers and bodies
| Hostcall | Status | Notes |
|---|---|---|
proxy_get_header_map_value | Supported | Case-insensitive lookup; a missing key reads as empty (the Rust SDK maps that to None). |
proxy_add_header_map_value | Supported | Appends (duplicate names possible). |
proxy_replace_header_map_value | Supported | Replaces the first case-insensitive match, or appends. |
proxy_remove_header_map_value | Supported | Removes every case-insensitive match. |
proxy_get_header_map_pairs | Supported | Request/response header maps in the spec wire format (LE entry count, LE length table, NUL-terminated strings — the layout the Rust SDK's get_map parses). Trailer maps read as empty. |
proxy_set_header_map_pairs | Partial | Replaces the whole map, parsing the same spec wire format (set_map output). Values are UTF-8 strings; a whole-map set cannot carry non-UTF-8 values (untouched headers keep theirs — see above). Trailer stores are accepted and discarded. |
proxy_get_buffer_bytes | Supported | Request body, response body, the delivered callout response body, plugin configuration, VM configuration. |
proxy_get_buffer_status | Supported | Lengths for the same buffers. |
proxy_set_buffer_bytes | Partial | Request and response bodies only. The write replaces the buffer from the offset to the end (spec-range in-place edits are not supported). Configuration buffers are read-only. |
proxy_send_local_response | Supported | Short-circuits the request: status, headers (spec wire format, exactly what the Rust SDK's send_http_response serializes), and body are honored verbatim, the upstream is never dialed. Status-code details and the gRPC status parameter are ignored. A non-empty body without a content-type defaults to application/json. |
proxy_log | Partial | Lines are captured in the per-instance buffer, capped at 64 KiB per instance (past the cap, the head of the overflowing line plus a truncation marker is kept and further lines are dropped); they are not surfaced to the gateway log today. No level filtering. |
proxy_get_log_level | Partial | Always reports Info. |
proxy_get_current_time_nanoseconds | Supported | Nanoseconds since the Unix epoch. |
Shared state, metrics, and callouts
| Hostcall | Status | Notes |
|---|---|---|
proxy_get_shared_data / proxy_set_shared_data | Partial | A VM-scoped key/value map with CAS: one map per compiled plugin module, shared by every per-request instance of that plugin (the proxy-wasm contract; what a callout plugin's TTL cache is built on). Not shared across different plugins; a plugin definition change (.wasm checksum or config bytes) starts a fresh map on reload. The map is capped per module: at most 1024 entries and 1 MiB of key + value bytes. A set that would exceed either cap fails with Err instead of evicting — entries are never silently removed (a vanished rate-limit counter is a correctness bug; a refused set is a signal you can branch on). The Rust SDK surfaces the refusal as Err(Status::CasMismatch): it is the only set_shared_data error status the SDK maps to a clean Err (every other status panics the module). A set that replaces an existing key stays allowed while it fits the byte cap, so updates never wedge. |
proxy_define_metric / proxy_record_metric / proxy_increment_metric / proxy_get_metric | Partial | Per-instance metric maps; not exported to the gateway's /metrics surface and reset every request. Emit gateway-visible signals from your plugin config or headers instead. |
proxy_http_call | Supported | HTTP callouts to http:// and https:// targets. See HTTP callouts for the pause/resume contract, guardrails, and failure semantics. |
proxy_grpc_call / proxy_grpc_stream | Stub | Return InternalFailure; the SDK's dispatch_grpc_call / open_grpc_stream surface that as Err. No gRPC callouts. |
proxy_grpc_send / proxy_grpc_cancel / proxy_grpc_close | Stub | Return InternalFailure. The SDK wrappers for these (send_grpc_stream_message, cancel_grpc_call/cancel_grpc_stream) only map BadArgument/NotFound to Err — they panic on InternalFailure, which traps the module and fails closed as 500 plugin_failed. Host-safe, but the plugin dies rather than handling the error. |
proxy_get_status | Partial | Returns Ok with code 0 and no message: no gRPC callout is ever in flight, so there is no status to report. The SDK's get_grpc_status reads that as (0, None) (it panics on any non-Ok status, so the host must answer Ok). |
proxy_register_shared_queue / proxy_resolve_shared_queue / proxy_dequeue_shared_queue / proxy_enqueue_shared_queue | Stub | Return InternalFailure; no shared queues. |
proxy_set_tick_period_milliseconds | Stub | Returns InternalFailure; there is no tick loop. on_tick never fires. |
proxy_call_foreign_function | Stub | Returns InternalFailure. |
Properties, contexts, streams
| Hostcall | Status | Notes |
|---|---|---|
proxy_get_property | Stub | Returns an empty value for every path — no properties resolve. |
proxy_set_property | Stub | No-op (reports success, changes nothing). |
proxy_set_effective_context | Partial | The ID is recorded; cross-context callbacks are not re-dispatched. |
proxy_continue_stream | Partial | No-op (reports success). |
proxy_close_stream | Partial | Ends the stream (same as returning EndStream). |
proxy_done | Supported | Marks the context done. |
The WASI subset (wasm32-wasip1)
A plugin compiled for wasm32-wasip1 emits a small wasi_snapshot_preview1 import set. The host provides minimal stubs for exactly that set:
| Import | Behavior |
|---|---|
environ_get / environ_sizes_get | Empty environment. |
fd_write | Routed into the per-instance log buffer (panic output included), under the same 64 KiB per-instance cap as proxy_log (a plugin spewing through stderr cannot grow host memory without bound). Like proxy_log, that buffer is not surfaced to the gateway log today. |
proc_exit | Traps the instance — reported like any plugin failure. |
random_get | Non-cryptographic. Do not use it for key material. |
clock_time_get | Wall-clock nanoseconds. |
sched_yield | No-op. |
A module importing any other WASI function (files, sockets, more) fails to instantiate, and routes referencing it fail closed. Keep plugins to the subset above.
HTTP callouts (proxy_http_call)
A plugin can make one or more HTTP requests to an external service during a phase and act on the answer — entitlement checks, experiment bucketing, fraud scores. The flow follows the proxy-wasm contract:
- your phase callback calls
dispatch_http_call(uri, headers, body, trailers, timeout)and returnsAction::Pause; - the gateway performs the exchange (the URI must be absolute
http://orhttps://— anything else answersErr(Status::BadArgument)without touching the network) and delivers the response toon_http_call_response(token_id, num_headers, body_size, num_trailers); - inside that callback you read the response through
get_http_call_response_header(s)(:statusrides the header map) andget_http_call_response_body, decide (stamp headers,send_http_response, or dispatch another callout), and resume.
The :method/:path pseudo-headers in the dispatch header map steer the request (:method defaults to GET; a map :path applies when the URI itself carries no path — the URI-embedded target wins). The dispatch headers' ordinary names ride the callout request; framing headers (host, content-length, connection, ...) are owned by the gateway. Request-head validation is fail-closed: :method and every header name must be valid HTTP tokens, and no name, value, or map :path may contain CR, LF, or NUL — the gateway interpolates these into the request head verbatim, so anything else would be request splitting. A violating dispatch answers Err(Status::BadArgument) before any connection is opened. The Rust SDK's HttpContext exposes the callback on its Context impl.
Guardrails (hard caps — no config knob):
| Guard | Value |
|---|---|
Timeout (your Duration, clamped) | 1 ms .. 5 s, a whole-exchange wall clock |
| Callout rounds per phase per request | 8 (a runaway dispatch loop fails closed, metric reason callout_loop) |
| Response body cap | 4 MiB (over-cap is an error, never truncation) |
| Request-head grammar | :method and header names must be HTTP tokens; names, values, and :path must be CR/LF/NUL-free — a violating dispatch answers Err(Status::BadArgument), no connection attempted |
| Redirects | not followed — a 3xx is delivered to you as data |
| SSRF | the gateway's ssrf_filter is applied at connect time against every resolved IP |
Failure semantics (a deliberate difference from Envoy): any COMPLETED response is delivered to your plugin — non-2xx included, because only your plugin knows what a 403 from its decision service means. A callout that cannot complete (timeout, refused connection, over-cap body, SSRF rejection) never reaches the callback: the route fails closed with 500 plugin_failed (metric reason callout_failed), exactly like a plugin trap. Envoy instead delivers an empty callback and lets the plugin branch; dwara does not, because a plugin must not resume as though its decision input had arrived. Recipes wanting fail-open behavior scope it explicitly — a short timeout plus a fallback implemented without a callout, or an extension trait instead of a plugin.
Every callout is counted in dwara_plugin_callouts_total{name,outcome} with the closed outcome set ok, timeout, error, loop_guard.
A runnable recipe (decision service + in-plugin TTL cache): demos/13-extensibility-usecases/07-per-request-decision/.
Plugin configuration
The gateway config's config string on a plugin entry is delivered as raw bytes: your on_configure implementation reads it with get_plugin_configuration() and parses it (JSON and YAML are typical). The VM-level configuration buffer is empty today — deliver everything through the plugin-level config field.
impl RootContext for MyPluginRoot {
fn on_configure(&mut self, _config_size: usize) -> bool {
let bytes = self.get_plugin_configuration().unwrap_or_default();
// parse `bytes`...
true // returning false fails the plugin load (routes fail closed)
}
}Resource limits and failure semantics
Limits come from the plugin's limits block (defaults: fuel: 1000000, memory_mb: 32, timeout_ms: 100). Every limit violation fails closed — the request does not silently skip the plugin:
| Limit | Enforcement | Client-visible result |
|---|---|---|
fuel | wasmtime fuel consumed per module instance; exhaustion traps | 500 plugin_failed |
memory_mb | linear-memory growth beyond the cap fails the allocation (a trap) | 500 plugin_failed |
timeout_ms | An epoch deadline is armed, but no epoch ticker runs by default — the deadline never fires on its own. Fuel is the effective execution bound today; size fuel accordingly (a busy-loop plugin dies on fuel, not on timeout_ms). | — |
Other request-path failures, all fail-closed on the referencing route only:
- Over-cap body: a route whose plugins declare a body phase buffers the body up to the route's
limits.max_body_bytes(default 1 MiB). A larger body answers 500plugin_body_too_large. - Callout failure or runaway loop: a plugin callout that cannot complete (timeout, refused, over-cap) answers 500
plugin_failed(metric reasoncallout_failed); a plugin exceeding 8 callout rounds in one phase answers 500plugin_failed(metric reasoncallout_loop). See HTTP callouts. - Streaming and encoded responses skip
response_body: SSE (text/event-stream), un-framed (no content-length) bodies, and content-encoded bodies pass through untouched — the skip is logged once (plugin_response_body_skipped), never silently. Never write aresponse_bodyplugin that must run for SSE routes. - Crashed, disabled, or unloadable plugin: 500
plugin_unavailable(see below).
Every failure increments dwara_plugin_failures_total{name,reason} (reasons include instantiate_failed, trap, crashed, body_too_large, response_stream_ended, callout_failed, callout_loop) and a request answered by a plugin (short-circuit or failure) sets the access log's plugin_short_circuit flag.
Caching and signature interactions
- Response cache: changing a plugin definition (its
configbytes or the.wasmchecksum) bumps the response-cache epoch of every route referencing that plugin on reload — cached pre-old-plugin response bytes are never replayed against the new plugin. - HMAC-signed routes: a
request_bodyplugin that modifies the body on a route with HMAC request signing will fail the signature check — the digest is computed over the original bytes, so the signed route answers 401signature_body_mismatch. Do not put body-rewriting plugins on HMAC-signed routes. - Duplicate references: a route naming the same plugin twice is a config validation error (the double execution would otherwise be literal). Reference each plugin at most once per route.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Config rejected: unknown wasm/native combination or empty phases | Schema validation | A plugin needs exactly one of wasm:/native: and a non-empty phases list. |
| Config rejected: route references an undefined plugin | Snapshot validation | Every name in a route's plugins list must match a top-level plugin entry, with no duplicates. |
plugin_load_failed in the gateway log, route answers 500 plugin_unavailable | The .wasm could not be read (path wrong, permissions) — the plugin is marked Crashed at publish; the rest of the config loads | Fix the path and reload. |
plugin_compile_failed, same 500 | The module failed to compile or failed ABI validation (missing proxy_on_vm_start, no exported memory, unknown host imports) | Rebuild against the scaffold's SDK version; check the WASI subset above. |
500 plugin_unavailable with instantiate_failed | Per-request instantiation failed — most commonly a WASI import outside the stub set, on_configure returning false, or a trap inside _start (the error names _start) | Same as above. |
500 plugin_failed with reason trap | The plugin trapped: fuel exhaustion (the usual), a memory-cap allocation failure, or a Rust panic inside the plugin | Raise fuel/memory_mb if legitimate; panics print through fd_write into the per-instance buffer, which is not surfaced today — reproduce the plugin logic outside the gateway (or in a test harness) to find the panic. |
500 plugin_body_too_large | A body-phase plugin saw a body over the route's cap | Raise limits.max_body_bytes on the route or narrow the phases the plugin declares. |
| Plugin changes not taking effect | Hot swap is checksum-keyed: identical .wasm bytes and config reuse the loaded module | Change the bytes (rebuild) or the config string, then reload. |
Nano-services are live today
If you need WASM that generates responses at the edge (rather than filtering proxied traffic), nano-services are live on every default build today: a route action type: nano_service runs a .wasm module's handler directly with no upstream. See Nano-services. Plugin filters and nano-services share the same sandbox model; pick the one that matches the job.
Testing plugins
For running plugins against a live gateway during development (the verified scaffold/build/load/curl loop, plus the harness patterns the demo scripts use), see Plugin testing.
Compatibility and versioning
Which proxy-wasm ABI level the host implements, the proxy-wasm crate version the scaffold pins, the wasm target matrix, and the breaking-change policy for the hostcall surface: Plugin compatibility.
Runnable demo
Run the verified round-trip end to end: demos/08-extensibility/ (test scripts: test-02-proxy-wasm.sh, test-07-plugin-sdk.sh). Both build a plugin from dwara-cli plugin new output, load it into a default-build gateway, and assert the header effect with curl; the category README covers prerequisites (prebuilt host binaries, the wasm32-wasip1 rustup target) and teardown.