Writing addons
An addon sees each request on its way to the rules and each response on its way back to the client (addon model). The simplest addon passes both through unchanged. This page shows that addon in each kind, as a starting point to change.
A WASM layer
A WASM addon is a WebAssembly component. Write one in Rust with the
roxy-addon
SDK. The crate is not on crates.io, so depend on it from the repository:
# Cargo.toml
[package]
name = "passthrough"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
roxy-addon = { git = "https://github.com/roxy-proxy/roxy-proxy" }// src/lib.rs
use roxy_addon::prelude::*;
struct Passthrough;
impl Layer for Passthrough {
fn init(_config: &str) -> Result<Self, String> { // the addon's `config:`, as JSON
Ok(Passthrough)
}
fn handle(&mut self, req: Request, next: Next) -> Response {
next.run(req)
}
}
roxy_addon::export!(Passthrough);Build it and point roxy at the component:
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2addons:
- name: passthrough
kind: wasm
path: target/wasm32-wasip2/release/passthrough.wasmhandle runs once per exchange. It gets the client's request and a
Next, and returns the response for the client:
Request { method, scheme, authority, path_with_query, headers, body }
Response { status, headers, body }next.run(req) passes the request on to the next addon or the rules, and
returns the response that comes back. Returning that response unchanged
passes everything through. A body passed on without being read never
enters the component, so this costs almost nothing.
From there, a layer can change the request or the response, or answer
the client itself without calling next.run:
fn handle(&mut self, req: Request, next: Next) -> Response {
let req = req.map_body(|body| body.transform(redact_chunk));
next.run(req)
}Bodystreams: read it as it arrives withtransform(per chunk) orpipe(with state, able to hold bytes back), or collect it withread_to_end(cap).flow::*andcall_endpointreach roxy's host services: tags, state, records, and calls to configured endpoints.- A panic fails the exchange closed.
- WebSockets need nothing extra: the request and response bodies carry them.
The test component
redact
is a complete streaming redactor.
A service layer
A service layer is a WebSocket server that roxy connects to. It must
accept the subprotocol roxy.layer.v7.
addons:
- name: passthrough
kind: service
endpoint: layer
endpoints:
layer: { url: "http://127.0.0.1:9000", private_ok: true }Each exchange is a stream on the connection, and every message names its
stream. A GET arrives like this:
{"stream":1,"type":"open","flow":"01M5…","layer":"passthrough","mode":"enforce", …}
{"stream":1,"type":"request","method":"GET","url":"http://example.com/","headers":[["accept","*/*"]]}
{"stream":1,"type":"request_end"}The service sends back the request to pass on, in the same shape. When the response comes back from upstream, roxy sends it the same way, and the service sends back the response for the client:
{"stream":1,"type":"response","status":200,"headers":[["content-type","text/html"]]}
{"stream":1,"type":"credit","dir":"response","bytes":262144}
{"stream":1,"type":"response_end"}Body bytes come as binary frames between a head and its end: a 4-byte
stream id, a direction byte (0 request, 1 response), then the bytes.
credit tells the service how many more body bytes roxy will take from
it.
To pass everything through unchanged, send every frame back exactly as it
arrived, except open and reset:
import asyncio
import json
from websockets.asyncio.server import serve
async def layer(ws):
async for frame in ws:
if isinstance(frame, str) and json.loads(frame)["type"] in ("open", "reset"):
continue
await ws.send(frame)
async def main():
async with serve(layer, "127.0.0.1", 9000, subprotocols=["roxy.layer.v7"]):
await asyncio.Future()
asyncio.run(main())This works because the service's replies have the same shape as what it
receives. Heads, body bytes, trailers and ends go back as the request and
response to pass on. Sending roxy's credit back gives roxy as much
credit as roxy gave the service, so roxy never sends more than the
service can pass on. open describes the exchange and needs no answer.
After a reset the stream is gone.
This is for mode: enforce. An observer gets a copy of the exchange and
sends none of it back. The service layer protocol
covers both modes, flow control, errors and host-service calls.
A real service usually wants each exchange as one task. The
quickstart's sidecar includes
roxy_layer.py,
a Python asyncio implementation of the protocol that handles the framing,
flow control and errors, and hands a handler one exchange at a time.
Other languages
Any language that builds WebAssembly components can implement
wit/addon.wit,
the roxy:addon@0.3.0 package, whose comments document each call. Go and JavaScript produce larger, slower
components. Python in WASM bundles an interpreter and needs raised memory
limits. For Python with native dependencies, or anything that should run
in its own process, write a service layer instead.
A component has no network, filesystem or environment access. Outbound
calls go through named endpoints, and output to stdout or stderr is
discarded: use flow.log.
Build a component against the wit/ of the roxy version it runs on. A
component built against another version fails when the config loads,
naming the imports that do not match.