Developing a plugin
A practical walkthrough — we'll build, test and ship the uppercase-extractor
WASM plugin (the same one in plugins/examples/wasm-extractor).
1. Scaffold
cargo new --lib uppercase-extractor && cd uppercase-extractor
mkdir wit && cp <loadr repo>/crates/loadr-plugin-api/wit/loadr.wit wit/
[package]
name = "uppercase-extractor"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.58"
serde_json = "1"
2. Implement
#![allow(unused)] fn main() { wit_bindgen::generate!({ path: "wit", world: "loadr-plugin" }); struct Plugin; impl exports::loadr::plugin::meta::Guest for Plugin { fn describe() -> exports::loadr::plugin::meta::Info { exports::loadr::plugin::meta::Info { name: "uppercase-extractor".into(), version: env!("CARGO_PKG_VERSION").into(), kind: "extractor".into(), description: "boundary extractor that upper-cases the match".into(), } } } impl exports::loadr::plugin::extractor::Guest for Plugin { fn extract(body: Vec<u8>, _headers: Vec<(String, String)>, config: String) -> Option<String> { let cfg: serde_json::Value = serde_json::from_str(&config).ok()?; let (left, right) = (cfg["left"].as_str()?, cfg["right"].as_str()?); let text = String::from_utf8_lossy(&body); let start = text.find(left)? + left.len(); let end = text[start..].find(right)? + start; Some(text[start..end].to_uppercase()) } } export!(Plugin); }
3. Build & package
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
mkdir dist
cp target/wasm32-wasip2/release/uppercase_extractor.wasm dist/
cat > dist/plugin.toml <<'EOF'
[plugin]
name = "uppercase-extractor"
version = "0.1.0"
kind = "extractor"
type = "wasm"
entry = "uppercase_extractor.wasm"
description = "Boundary extractor that upper-cases the match"
EOF
4. Install & use
loadr plugin install ./dist
loadr plugin info uppercase-extractor
plugins: [ { name: uppercase-extractor, config: { left: "token=", right: ";" } } ]
5. Publish to the index
A locally-installed directory is enough for development, but to make your
plugin installable by name (loadr plugin install <name>) it has to appear in
the plugin index — the catalogue described in
Installing plugins.
For each supported host target, package the plugin.toml plus the built
dynamic library into an archive (.tar.gz on Linux/macOS, .zip on Windows),
name it <name>-<target>.<ext>, and add an entry to plugins/index.json:
{
"schema": 1,
"plugins": {
"myproto": {
"kind": "protocol",
"description": "…",
"latest": "0.1.0",
"versions": {
"0.1.0": {
"min_loadr_abi": "1.0",
"artifacts": {
"x86_64-unknown-linux-gnu": {
"url": "https://…/myproto-x86_64-unknown-linux-gnu.tar.gz",
"sha256": "<sha256 of the archive>",
"entry": "libloadr_plugin_myproto.so"
}
}
}
}
}
}
}
The release CI fills in the real url/sha256 per target; bump
min_loadr_abi to the host ABI your build requires (the
LOADR_PLUGIN_ABI_VERSION you compiled against). The entry is the
per-platform artifact filename (libloadr_plugin_<name>.so /
.dylib / loadr_plugin_<name>.dll) and must match the entry inside the
archive's plugin.toml.
Until the index goes live you can hand a tester an archive directly:
loadr plugin install ./myproto-x86_64-unknown-linux-gnu.tar.gz --allow-untrusted
Testing tips
- Drive the component directly in a Rust test with
loadr_plugin_api::WasmExtractor::load(path)— exactly what loadr's own test suite does for the examples. - For native plugins: build with
cargo build, thenNativePlugin::load("target/debug/libmy_plugin.so")in a test. - Keep configs JSON-serializable and document them in your README; loadr
passes the
config:value through verbatim.
Versioning rules
- WASM: the WIT package version (
loadr:plugin@0.1.0) is the contract. - Native:
abi_stablelayout checking is the contract; additionally the root module carriesabi_version— bump on breaking changes and loadr will refuse mismatches with a clean message.
Native protocol plugins
A protocol plugin adds a new load-test target (a database, a queue, a
bespoke wire protocol). It must be a native plugin — WASM plugins can only be
extractors/assertions. loadr-plugin-mongo is the reference implementation; see
the MongoDB plugin for an end-to-end example.
The ABI
A protocol plugin implements the synchronous FfiProtocol trait and exports it
via make_protocol:
#![allow(unused)] fn main() { use loadr_plugin_api::abi::{FfiProtocol, FfiProtocolBox, FfiProtocol_TO, PluginMod, LOADR_PLUGIN_ABI_VERSION}; use loadr_plugin_api::{FfiRequest, FfiResponse}; use abi_stable::std_types::{RString, ROption::{RNone, RSome}}; struct MyProto; impl FfiProtocol for MyProto { fn name(&self) -> RString { RString::from("myproto") } fn execute(&self, request_json: RString) -> RString { // parse FfiRequest JSON, run the op, return FfiResponse JSON. // MUST NOT panic — report failures via the response `error` field. } } extern "C" fn make_protocol() -> FfiProtocolBox { FfiProtocol_TO::from_value(MyProto, abi_stable::erased_types::TD_Opaque) } extern "C" fn plugin_info() -> RString { /* PluginInfo JSON, incl. "schemes" */ } loadr_plugin_api::export_loadr_plugin! { PluginMod { abi_version: LOADR_PLUGIN_ABI_VERSION, info: plugin_info, make_output: RNone, make_protocol: RSome(make_protocol), make_service: RNone, } } }
Key facts that shape the design:
executeis synchronous, takes&self, and runs on one shared instance (Send + Sync) created once viamake_protocol(). There is no per-VU context across the FFI boundary.- A plugin that drives an async client (most do) must therefore own its async
machinery: create its own Tokio runtime inside the cdylib and
block_on, and keep an internal connection pool keyed by the connection target (e.g.OnceCell<Mutex<HashMap<String, Client>>>), reused across every call and VU. Do not connect per request. - Build the crate as
crate-type = ["cdylib"],publish = false, a member of the workspace underplugins/.
Request / response JSON
The host serializes a loadr_plugin_api::FfiRequest to JSON and hands it to
execute; the plugin returns a FfiResponse as JSON:
// FfiRequest (host -> plugin)
{
"name": "find users", // metric `name` tag
"method": "POST",
"url": "mongodb://h:27017/db", // the connection target / URL
"headers": [["k", "v"]],
"body_b64": "", // base64 request body
"timeout_ms": 30000,
"options": { ... }, // the request's `plugin:` block, ${...}-interpolated
"config": { ... } // merged plugin config (manifest [config] + PluginRef.config)
}
// FfiResponse (plugin -> host)
{
"status": 1, // your convention; non-failed by default
"status_text": "OK",
"headers": [],
"body_b64": "",
"duration_ms": 1.7,
"error": null, // Some(msg) => request is marked failed
"extras": { "docs": 3 } // free-form; the host can read fields out (see below)
}
The host already interpolates ${...} in the request's plugin: block before
the plugin sees it, so options arrives fully rendered.
Declaring the URL scheme(s) — routing contract
A runtime-loaded plugin cannot edit core, so it declares the URL scheme(s) it
serves and the host wires up routing automatically. Declare schemes in two
places (the manifest wins; info() is the fallback when a plugin is loaded by
bare path):
# plugin.toml
[plugin]
name = "myproto"
kind = "protocol"
type = "native"
entry = "libmyproto.so"
schemes = ["myproto", "myp"] # URL schemes this plugin claims
#![allow(unused)] fn main() { // plugin_info() JSON { "name": "myproto", "kind": "protocol", "schemes": ["myproto", "myp"], ... } }
When the host loads the plugin it registers those schemes with a process-global
scheme router (loadr_core::protocol::register_plugin_schemes). After that,
ProtocolRegistry::infer resolves a URL like myproto://host/... to the
handler whose name() is myproto. Built-in schemes always win over plugin
aliases, and an explicit protocol: myproto in YAML also resolves (it must
match the plugin handler's name(), which the validator accepts because it is
listed under plugins:).
So a test can target the plugin either way:
plugins: [ { name: myproto } ]
flow:
- request: { url: "myproto://host/...", plugin: { ... } } # routed by scheme
- request: { url: "host/...", protocol: myproto, plugin: { ... } } # routed by name
Metrics
The host derives a metric family from the handler name() for plugin
protocols, emitting <name>_reqs (counter), <name>_req_duration (trend), and
— when the response includes extras.docs — <name>_docs (counter). A response
with a non-null error increments http_req_failed. So loadr-plugin-mongo
(name mongo) produces mongo_reqs / mongo_req_duration / mongo_docs
without any core changes per plugin.
Testing
- Unit-test the
execute/handlelogic by buildingFfiRequestJSON and asserting on theFfiResponse— no host needed. - Integration-test against a real backend behind an env-var gate (e.g.
LOADR_TEST_MONGO_URL) so CI skips it when the service is absent; bring the service up viaexamples/harness/docker-compose.yml. - End-to-end, load the built artifact with
loadr_plugin_api::NativePlugin::load("target/debug/libmyproto.so").