splicer ๐โ๏ธ๐ชก
June 4, 2026 ยท View on GitHub
Plan and generate middleware splice operations for WebAssembly component composition graphs.
splicer reads:
- A component binary as
.wasm(the composition or a single component) - A splice configuration (YAML)
It produces a new composed .wasm (or the underlying plan pieces) that injects middleware components according to declarative rules.
splicer is interface-agnostic โ it operates on any WIT interface edge in a component graph.
The same splice rules apply whether the targeted interface is wasi:http/handler, wasi:cli/run, my:app/orders, or a custom in-house contract.
Why splicer?
When building component-based systems, middleware insertion often requires:
- Rewriting instantiation chains
- Re-threading handler references
- Maintaining correct edge ordering
- Traversing nested provider chains
splicer automates that planning step.
Instead of manually restructuring component wiring, you define declarative rules on:
- which interface to target,
- what middleware to wire in, and
- where to invoke the middleware.
splicer either produces the newly composed .wasm directly, or (with --plan) emits the underlying plan pieces (generated wac + split sub-components) for inspection or manual composition.
splicer operates directly on the component binary (no source code, and no original wac even when the input is itself a composition). The graph is discovered from the binary alone.
Adapter Components
Most middleware doesn't need to match the exact type signature of the interface
it's being placed on. A logging middleware that prints "before" and "after"
around every call works the same whether the target interface is
my:service/adder, wasi:cli/run, or wasi:http/handler; it only needs
the function name.
Splicer generates adapter components that bridge between a generic middleware WIT interface and the specific target interface. The middleware author writes against a simple contract; splicer handles all the type plumbing at composition time.
Middleware Tiers
| Tier | Data access | Calls downstream? | Capability | Contract | Status |
|---|---|---|---|---|---|
| Tier 1 | none (call-id only) | yes (skippable) | Hooks: middleware sees the call identity but not types or data | wit/tier1/world.wit | Supported |
| Tier 2 | read-only | yes (skippable) | Observe: middleware sees the typed values flowing through; cannot modify | wit/tier2/world.wit | Supported |
| Tier 3 | read + write | yes | Transform: middleware sees AND modifies the values; downstream is still called | splicer_tool_sdk::TransformStrategy | Supported |
| Tier 4 | read + write | no | Virtualize: middleware replaces the downstream entirely (mocks, virts, replayers) | splicer_tool_sdk::VirtualizeStrategy | Supported |
Each tier strictly adds one capability. Middleware written for a lower tier works unchanged when higher tiers become available.
"Skippable" (tier 1 / tier 2) vs "no" (tier 4) are different things.
With gate::should-call the adapter still generates the
downstream call and asks the middleware at runtime whether to invoke
it. It's a per-call gate.
With virtualization the downstream call is not in the adapter at
all; it cannot be reached, regardless of runtime state.
Tier-1 and tier-2 middleware are components: your wasm exports one
or more of the interfaces defined in the relevant tier WIT world (e.g.
wit/tier1/world.wit). Tier-2 hooks receive
arguments and results lifted into a structural field-tree (defined in
wit/common/world.wit), so observation middleware
can inspect typed values without depending on the target interface's
concrete types.
When splicer splice detects that a middleware exports these interfaces (instead
of the target interface directly), it automatically generates an adapter
component and wires it into the composition.
Tier-3 and tier-4 middleware are Rust strategy crates implementing
TransformStrategy or
VirtualizeStrategy from
splicer-tool-sdk. Splicer codegens a per-target
wrapper at splice-time; the wrapper is the adapter. See
tier-3 / tier-4.
Tier-3/4 currently ships only via builtins. User-form tier-3/4 (point splicer at your own strategy crate) is planned.
For Rust authors, splicer-tool-sdk ships
ready-made building blocks for middleware and downstream tools. Common
operations on lifted typed values live in one place, so your middleware
and any consuming tools (decoders, replay drivers, fixture sanitizers)
get them for free instead of each crate re-implementing them.
For the full guide โ including how to write a middleware, how adapter detection works, and what the generated adapter does internally โ see docs/adapter-components.md.
Builtins
Splicer ships middleware as builtins, referenced by name in a splice config:
inject:
- builtin: hello-tier1
Tier-1/2 builtins are pre-built wasm fetched on demand from
ghcr.io/ejrgilbert/splicer/builtins/*. Tier-3/4 builtins are Rust
strategy crates embedded in splicer's binary; the wrapper is codegen'd
and compiled per-target at splice-time (requires cargo and
wasm32-wasip1 on PATH).
| Name | Tier | Description |
|---|---|---|
hello-tier1 | 1 | println!s every wrapped call. Verifies splice rules fire. |
hello-tier2 | 2 | println!s every wrapped call with lifted arg + result values. |
hello-tier3 | 3 | Pass-through transform; println!s before/after each wrapped call. |
hello-tier4 | 4 | Returns R::default() instead of forwarding. Requires R: Default. |
otel-bare-spans | 1 | Emits a wasi:otel span per call (timing + call-id attrs, no payload). |
otel-bare-metrics | 1 | Emits wasi:otel count + duration-histogram metrics (no payload). |
otel-bare-logs | 1 | Emits a structured wasi:otel log per call (configurable severity, no payload). |
See docs/splice-config.md
for the full builtin: schema (short + long forms, the config: block,
and the local-override โ cache โ OCI resolution order).
The CLI includes helpful information on builtins. Run: splicer builtin to view.
Build + Install
From crates.io:
cargo install splicer
Or as a library dependency:
[dependencies]
splicer = "2"
The library entry point is splicer::splice(SpliceRequest) -> Bundle;
examples/wac_compose.rs is a runnable end-to-end demo.
From source (for development):
cargo build --release
# binary at target/release/splicer
Builtin source crates live under builtins/. You don't
need to build them to use splicer, they're pulled from
ghcr.io/ejrgilbert/splicer/builtins/* on demand. To rebuild local
artifacts (for iterating on a builtin without re-publishing), run:
make build-builtins
Builds land in assets/builtins/; point SPLICER_BUILTINS_DIR at that
directory to short-circuit the OCI pull.
To kick the tires, cargo run --example demo runs a self-contained
demo; for a fuller walkthrough see the external
component-interposition
repo.
Configuration Format
Splicing behavior is defined in a YAML configuration file:
version: 1
rules:
- before:
interface: my:app/orders
provider:
name: validate
inject:
- builtin: hello-tier1
Rules can match on three independent axes (mix and match as needed):
| Axis | Field | What it gates |
|---|---|---|
| Interface name | interface: | The target interface's name (glob or list, required). |
| Node name | provider: / inner: / outer: | The instance's display name on the provider/inner/outer side (glob or list, optional). |
| Function shape | all-funcs: | The matched interface's function shapes โ async, scope, arg/result properties (optional). |
See docs/splice-config.md for the full
specification.
Testing
Unit tests cover the adapter generator, WAC emitter, and composition
planner: cargo test --lib.
End-to-end coverage lives in tests/fuzz_and_run.rs. It scaffolds
provider/consumer/middleware crates, drives them through the full
splicer pipeline (compose + splice, before and between), and
invokes the result under wasmtime. Two entry points (both
#[ignore]'d โ they build real crates):
test_cannedโ a hardcoded catalog of value-type shapes crossed with async modes and split-kind pipelines. Deterministic; the regression canary.test_fuzzโarbitrary-driven random shapes, reproducible viaSPLICER_FUZZ_SEED(replay any failing iter by re-running with its seed andSPLICER_FUZZ_ITERS=1).
cargo test --test fuzz_and_run -- --ignored --nocapture test_canned
cargo test --test fuzz_and_run -- --ignored --nocapture test_fuzz
Env knobs:
| var | default | effect |
|---|---|---|
SPLICER_FUZZ_SEED | 0xDEADBEEF | base RNG seed; each iter's shape uses seed + iter_idx |
SPLICER_FUZZ_ITERS | 30 | iterations per async mode (sync + async both run) |
SPLICER_FUZZ_DEPTH | 4 | max recursion depth for compound shapes |
SPLICER_KEEP_TMPDIR | unset | preserve the tempdir for post-mortem inspection |