Skip to content

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 ​

sh
dwara-cli plugin new my-plugin
cd my-plugin

This 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:

sh
dwara-cli plugin new my-plugin --template static-auth

2. 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:

rust
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):

sh
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1

The 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:

yaml
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_headers

The wasm: path is resolved relative to the gateway process's working directory — prefer an absolute path outside throwaway setups.

5. Run and verify ​

sh
DWARA_CONFIG=dwara.yaml dwara
curl -i http://127.0.0.1:8080/api/hello

The 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:

  1. instantiates a fresh module instance (fuel and memory limits from the plugin's limits block);
  2. calls _start, proxy_on_context_create(1, 0), proxy_on_vm_start, then proxy_on_configure (SDK modules need this sequence; minimal ABI modules without those exports skip them);
  3. 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;
  4. always calls proxy_on_done / proxy_on_log / proxy_on_delete at 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 ​

HostcallStatusNotes
proxy_get_header_map_valueSupportedCase-insensitive lookup; a missing key reads as empty (the Rust SDK maps that to None).
proxy_add_header_map_valueSupportedAppends (duplicate names possible).
proxy_replace_header_map_valueSupportedReplaces the first case-insensitive match, or appends.
proxy_remove_header_map_valueSupportedRemoves every case-insensitive match.
proxy_get_header_map_pairsSupportedRequest/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_pairsPartialReplaces 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_bytesSupportedRequest body, response body, the delivered callout response body, plugin configuration, VM configuration.
proxy_get_buffer_statusSupportedLengths for the same buffers.
proxy_set_buffer_bytesPartialRequest 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_responseSupportedShort-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_logPartialLines 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_levelPartialAlways reports Info.
proxy_get_current_time_nanosecondsSupportedNanoseconds since the Unix epoch.

Shared state, metrics, and callouts ​

HostcallStatusNotes
proxy_get_shared_data / proxy_set_shared_dataPartialA 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_metricPartialPer-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_callSupportedHTTP callouts to http:// and https:// targets. See HTTP callouts for the pause/resume contract, guardrails, and failure semantics.
proxy_grpc_call / proxy_grpc_streamStubReturn 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_closeStubReturn 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_statusPartialReturns 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_queueStubReturn InternalFailure; no shared queues.
proxy_set_tick_period_millisecondsStubReturns InternalFailure; there is no tick loop. on_tick never fires.
proxy_call_foreign_functionStubReturns InternalFailure.

Properties, contexts, streams ​

HostcallStatusNotes
proxy_get_propertyStubReturns an empty value for every path — no properties resolve.
proxy_set_propertyStubNo-op (reports success, changes nothing).
proxy_set_effective_contextPartialThe ID is recorded; cross-context callbacks are not re-dispatched.
proxy_continue_streamPartialNo-op (reports success).
proxy_close_streamPartialEnds the stream (same as returning EndStream).
proxy_doneSupportedMarks 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:

ImportBehavior
environ_get / environ_sizes_getEmpty environment.
fd_writeRouted 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_exitTraps the instance — reported like any plugin failure.
random_getNon-cryptographic. Do not use it for key material.
clock_time_getWall-clock nanoseconds.
sched_yieldNo-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:

  1. your phase callback calls dispatch_http_call(uri, headers, body, trailers, timeout) and returns Action::Pause;
  2. the gateway performs the exchange (the URI must be absolute http:// or https:// — anything else answers Err(Status::BadArgument) without touching the network) and delivers the response to on_http_call_response(token_id, num_headers, body_size, num_trailers);
  3. inside that callback you read the response through get_http_call_response_header(s) (:status rides the header map) and get_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):

GuardValue
Timeout (your Duration, clamped)1 ms .. 5 s, a whole-exchange wall clock
Callout rounds per phase per request8 (a runaway dispatch loop fails closed, metric reason callout_loop)
Response body cap4 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
Redirectsnot followed — a 3xx is delivered to you as data
SSRFthe 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.

rust
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:

LimitEnforcementClient-visible result
fuelwasmtime fuel consumed per module instance; exhaustion traps500 plugin_failed
memory_mblinear-memory growth beyond the cap fails the allocation (a trap)500 plugin_failed
timeout_msAn 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 500 plugin_body_too_large.
  • Callout failure or runaway loop: a plugin callout that cannot complete (timeout, refused, over-cap) answers 500 plugin_failed (metric reason callout_failed); a plugin exceeding 8 callout rounds in one phase answers 500 plugin_failed (metric reason callout_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 a response_body plugin 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 config bytes or the .wasm checksum) 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_body plugin 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 401 signature_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 ​

SymptomCauseFix
Config rejected: unknown wasm/native combination or empty phasesSchema validationA plugin needs exactly one of wasm:/native: and a non-empty phases list.
Config rejected: route references an undefined pluginSnapshot validationEvery 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_unavailableThe .wasm could not be read (path wrong, permissions) — the plugin is marked Crashed at publish; the rest of the config loadsFix the path and reload.
plugin_compile_failed, same 500The 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_failedPer-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 trapThe plugin trapped: fuel exhaustion (the usual), a memory-cap allocation failure, or a Rust panic inside the pluginRaise 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_largeA body-phase plugin saw a body over the route's capRaise limits.max_body_bytes on the route or narrow the phases the plugin declares.
Plugin changes not taking effectHot swap is checksum-keyed: identical .wasm bytes and config reuse the loaded moduleChange 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.