Last modified: 2026-08-16
This guide covers writing WebAssembly modules for sbproxy's wasm
transform. Two minimal example modules live in examples/wasm/,
one in Rust and one in TinyGo. Both compile against the same WASI
preview-1 contract; pick the toolchain you prefer.
The other scripting engines (CEL, Lua, JavaScript) cover most needs inside a single language. WASM is the right pick when you want:
- A language sbproxy does not ship a first-class engine for (Rust, TinyGo, AssemblyScript, Zig, Swift, C/C++).
- Stronger isolation than an interpreter. Each invocation gets a
fresh
Storewith capped memory and a wall-clock deadline. - Reuse of a compiled body-transform module across origins or environments without rewriting in the proxy's scripting languages.
WASM transforms run after the upstream response has been buffered and replace the response body. They cannot read the request, modify headers, or short-circuit the response.
The host invokes the module's WASI _start export once per request.
There is no custom calling convention. The host pipes:
| Channel | Direction | Contents |
|---|---|---|
| stdin | host -> module | The full upstream response body |
| stdout | module -> host | The new response body |
| stderr | module -> host | Captured for debug logging |
Whatever the module writes to stdout becomes the new response body.
If the module writes nothing, the body becomes empty. If _start
traps (panics, hits the timeout, exhausts memory), the transform
fails and the request follows the standard transform error path
(see transforms.failure_posture in configuration.md; default
open skips the failed transform and forwards the unmodified
upstream body).
That is the whole ABI. No imports beyond standard WASI. No exports
beyond _start. Any wasm32-wasi binary that reads stdin and
writes stdout works.
use std::io::{self, Read, Write};
fn main() {
let mut buf = Vec::new();
let _ = io::stdin().read_to_end(&mut buf);
// Real transforms mutate `buf`. This one just echoes.
let _ = io::stdout().write_all(&buf);
}Build:
cargo build --release --target wasm32-wasiNewer Rust toolchains renamed the target wasm32-wasip1; either name
produces the same WASI preview-1 module, so use whichever your
toolchain accepts. The output
target/wasm32-wasi/release/<crate>.wasm is what you point sbproxy
at. The full example is in examples/wasm/echo-rust/,
including a Docker-based build script so contributors do not need
to install rustup or the wasm32-wasi target locally.
package main
import (
"bytes"
"io"
"os"
)
func main() {
body, err := io.ReadAll(os.Stdin)
if err != nil {
return
}
_, _ = os.Stdout.Write(bytes.ToUpper(body))
}Build:
tinygo build -o uppercase.wasm -target=wasi -no-debug main.goThe full example is in examples/wasm/uppercase-tinygo/. The
-no-debug flag is worth keeping; debug info inflates the module
size by 5x to 10x for trivial programs. TinyGo's WASI target lacks
parts of the Go standard library (net, os/exec, anything that
needs a real OS), but the basics (io, bytes, strings, unicode,
encoding/json, regexp) all work.
The echo module round-trips the body unchanged inside the sandbox limits (config).
origins:
"wasm.local":
action:
type: static
status_code: 200
content_type: text/plain
body: "hello from sbproxy"
transforms:
- type: wasm
module_path: examples/wasm/echo-rust/echo.wasm
timeout_ms: 500
max_memory_pages: 256This is examples/wasm-transform/sb.yml, runnable as shipped against
the checked-in echo module. Start it from the repository root (the
relative module_path resolves from the working directory) and fetch
the transformed body:
make run CONFIG=examples/wasm-transform/sb.yml
curl -i -H 'Host: wasm.local' http://127.0.0.1:8080/HTTP/1.1 200 OK
content-type: text/plain
content-length: 18
Date: Sun, 02 Aug 2026 03:44:27 GMT
Connection: keep-alive
hello from sbproxy
The static body went through the module's stdin and came back from its
stdout unchanged, because echo copies one to the other. Any other
wasm32-wasi binary in module_path does its own work in the same
position.
Field reference:
| Field | Default | Notes |
|---|---|---|
module_path |
required (or module_bytes) |
Path to the .wasm, resolved relative to the proxy's working directory. Use an absolute path in production. |
module_bytes |
required (or module_path) |
Inline bytes. Most useful when configs are fetched from a control plane that already has the module bytes. |
timeout_ms |
1000 | Hard wall-clock cap. Enforced via wasmtime's epoch interruption, ticked once per millisecond. A module that doesn't yield within this many ticks is aborted with Trap. |
max_memory_pages |
256 | Linear-memory cap in 64 KiB pages. 256 pages = 16 MiB. Raise for transforms that buffer large bodies. Allocations past this cap trap. |
max_fuel |
1,000,000,000 | Maximum wasmtime fuel (roughly, instructions) one invocation may consume. A deterministic, instruction-granular complement to timeout_ms; lower it to put a hard ceiling on per-call CPU. Exhausting the budget traps. |
sha256 |
none | Optional lowercase hex SHA-256 the selected module bytes must match. Checked before compilation; a mismatch fails config load without compiling or naming the path in the error. |
Module compilation happens once at config load. A bogus path or a
malformed .wasm fails the load (the proxy will not start with a
broken transform), which surfaces problems at deploy time rather
than at first request.
There is no allowed_hosts: field. An older build accepted one and
never enforced it. That is a bad shape for a key that reads as a
security boundary, so the transform refuses it at config compile now
and says why. Nothing is lost: a module has no way to open a socket in
the first place, so the allowlist was never gating anything. If a host
callout lands later, the key returns as an enforced one and fails
closed from the start.
What the host enforces:
- Memory.
max_memory_pagescaps the module's linear memory. A module that grows past this cap traps on the offendingmemory.growor allocator call. - CPU.
timeout_msis enforced via epoch interruption. A background thread bumps the engine's epoch once per millisecond; the module is interrupted at the next instruction boundary after the deadline. - Filesystem. No preopens. The module sees an empty FS.
- Network. Not exposed, and no config key claims otherwise. There are no WASI sockets and no host callout function, so there is nothing for a host allowlist to allow.
- Environment. No environment variables forwarded;
std::envreads return empty. - Random. WASI's
random_getis allowed and produces cryptographically random bytes from the host. Use this for any randomness; do not seed from a fixed value. - Time. WASI's clock is allowed (modules can read wall-clock and monotonic time). The host does not pin or skew the clock.
What the module observes:
- A working stdin (the response body) and stdout (the new body).
- A working stderr that the host pipes to the proxy's debug log.
- A WASI clock and a WASI random source.
- Nothing else. No FS, no network, no env, no
args.
The wasmtime Engine is shared process-wide and the compiled
Module is cached per wasm transform. Per-request cost is one
fresh Store, one instantiate, and one _start call. For a
trivial transform (under a few KB of .wasm) that adds up to tens
of microseconds plus whatever the module itself does.
Tips:
- Keep the module small. A Rust binary built with default settings
ships ~200 KB of bytecode for a hello world. Adding
[profile.release] opt-level = "z",lto = true, andstrip = truetypically cuts that to under 50 KB. TinyGo with-no-debugis similar. - Avoid heap allocations in the hot path where you can. The Rust echo
example takes the simple route and buffers the whole body with
read_to_endinto aVecbefore writing it back; that is fine for small bodies and easy to follow. - Buffer the body to a
Veconly when you actually need random access. Streaming transforms (uppercase, gzip, JSON-line filters) can process stdin chunk by chunk, or round-trip withio::copywithout buffering more than a stack frame. - The first call after process start triggers compilation if the module has not been cached. Subsequent calls reuse the compiled module across requests.
A WASM transform that traps is logged at warn level with the trap type (epoch deadline, memory exhaustion, unreachable, etc.) and the guest stack frame names if available. To get more from the module itself:
-
Write debug output to stderr. The host captures it on both module paths: the
type: wasmtransform and the config-loaded extension bundle. Bundle lines are tagged with the bundle name, hook kind, andtype:string, so a bundle carrying several hooks stays readable. Capture is bounded at 1 MiB per call and the overflow is dropped with a truncation line rather than failing the call, so a chatty guest is never killed by its own logging.The level depends on whether the call failed. Release builds are compiled with
release_max_level_info, which stripsdebug!entirely, so a healthy guest's stderr is visible only in a debug build even with--log-level debug. A guest that trapped, exhausted its memory or table budget, or exited non-zero has its captured stderr emitted atwarninstead, which survives release. In practice that means the diagnostics are there when you need them and cost nothing when you do not. -
Add a feature flag in your module that emits a hex dump of the input on stderr. Cheaper than a full debugger, often enough to diagnose payload mismatches.
-
Validate the module locally with
wasmtime run --invoke _start module.wasm < input.txt > output.txtbefore wiring it into a proxy config. The same wasmtime version sbproxy uses is in thewasmtimeworkspace dependency inCargo.toml.
Forgetting _start. If you build with a cdylib crate type or
a TinyGo target that omits _start, instantiation fails with
"module is missing the WASI _start export". Use the default
binary crate type for Rust and -target=wasi for TinyGo.
Output not flushed. Stdout in wasm32-wasi is line-buffered for
text and unbuffered for write_all of bytes. Both example modules
write the whole body in one write_all call, which the host sees
as soon as _start returns. If your module uses print! or a
formatted writer, call .flush() before exiting or use writeln!
on a buffered writer that flushes on drop.
Reading more than the body. stdin contains exactly the response
body bytes the upstream sent. There is no framing, no header, no
trailer. read_to_end is the right tool; do not try to consume a
specific number of bytes unless you know the body length.
Holding the timeout open. timeout_ms is wall clock, not CPU
time. A module that sleeps (TinyGo's time.Sleep, Rust's
std::thread::sleep if you compile a runtime that supports it)
still counts against the deadline.
There is no in-band module versioning. Two patterns work in practice:
- File-name versioning. Bake the version into the file name
(
uppercase-v3.wasm) and update the config to point at the new file. Combine with the proxy's hot reload to swap modules without restarting. - Inline bytes. Keep the module in the config store so the control plane can bump versions atomically with the rest of the config.
There is no migration story today for modules that need to maintain state across requests; the WASI sandbox is per-invocation by design.
- scripting.md - the broader scripting overview (CEL, Lua, JavaScript, WASM).
- configuration.md - the full transform field
reference, including
fail_on_errorsemantics. examples/wasm-transform/sb.yml- the runnable end-to-end example used in this guide.examples/wasm/echo-rust/- the Rust hello-world module with a Docker-based build script.examples/wasm/uppercase-tinygo/- the TinyGo equivalent.
