Adapter Components
June 4, 2026 · View on GitHub
When you splice middleware into a composition, the middleware component
needs to export the same interface it's being inserted on. A logging
middleware that wraps wasi:http/handler would normally need to import
and export the full wasi:http/handler interface — complete with all
its resource types, error variants, and async function signatures.
That's a lot of boilerplate, and it means every middleware is locked to
one specific interface. A logging component built for wasi:http/handler
can't be reused on my:service/adder without being rewritten.
Adapter components solve this. Instead of requiring middleware to match the target interface signature, splicer generates a thin wrapper component, the adapter, that bridges between a generic middleware WIT interface and the specific target interface. The middleware author writes against a tier-specific WIT contract — fully type-erased at tier 1 (the middleware sees only call identity), structural / typed but target-agnostic at tiers 2 and up (the adapter lifts canonical-ABI values into a uniform field representation). Splicer handles all the type plumbing at composition time either way.
This document covers the shared framework that applies to every tier: tier taxonomy, the rules common to all tiers, eligibility detection, and chain composition. For each tier's deep dive, see the per-tier docs:
- Tier 1: Name-Only Hooks — shipped
- Tier 2: Observation — shipped
- Tier 3: Transform — shipped
- Tier 4: Virtualize — shipped
For a low-level architecture walkthrough of the generator itself, see
adapter-internals.md.
Middleware Tiers
Not all middleware needs the same level of access to function arguments and return values. Splicer defines four tiers of middleware capability, each with its own WIT interface. The generated adapter component knows which tier to use based on which interfaces the middleware exports.
The four tiers split along two cuts: whether the middleware can see the call's typed payload, and what it does with that visibility. Tiers 1 and 2 leave the call flowing through unchanged (observation only). Tier 3 lets the middleware modify what flows through; tier 4 lets it replace the downstream entirely.
| Tier | See call name | See typed data | Modify data | Bypass downstream | Status |
|---|---|---|---|---|---|
| 1 | yes | no | no | partial (block) | supported |
| 2 | yes | yes | no | no | supported |
| 3 | yes | yes | yes | no | supported (builtin) |
| 4 | yes | yes | yes | yes | supported (builtin) |
The tiers split along two emit-path families:
- Tiers 1 and 2 are wasm-component middleware against a WIT hook
ABI. Splicer generates a separate adapter component that lifts
canonical-ABI values into a uniform structural representation
(
field-treefor tier-2) and fires the middleware's hooks. Two artifacts: the middleware + the generated adapter. - Tiers 3 and 4 are Rust strategy crates against the
splicer-tool-sdkstrategy traits. Splicer reads the target's WIT, runswit-bindgento get typed Rust bindings, and emits a wrapper crate that dispatches each method to the strategy. The wrapper IS the adapter — one artifact. See tier-3.md and tier-4.md.
Each tier strictly adds one capability over the previous. Middleware written for a lower tier works unchanged when higher tiers become available — the tier is determined by which WIT interfaces the middleware exports, and the adapter generator picks the right strategy automatically.
Cross-tier rules
These apply to every tier; the per-tier docs only cover what's tier-specific.
Middleware imports
A middleware is just a regular component — it can declare any imports
it needs (wasi:filesystem for backing storage, wasi:io for streams,
custom interfaces for fixtures, etc.). The adapter only wires up the
middleware's tier-N export to the target interface; the middleware's
own imports are left untouched and get satisfied by the surrounding
composition (or by the host) like any other component would. This
matters most for tier 4 (a virt almost always needs a backend), but
the rule is the same everywhere.
Async-only hooks
All tier WIT worlds export their hook functions as async. The
adapter emits async dispatch unconditionally, so middleware authors
write async fn regardless of whether the wrapped target function is
sync or async. This keeps the adapter's async machinery uniform and
lets the middleware freely await imports of its own without needing a
separate sync code path.
Adapter behavior when a middleware hook traps
If a middleware's hook (any tier's on-call, on-return,
should-call, etc.) itself traps — e.g. the middleware panics,
dereferences out-of-bounds memory, or otherwise hits an unrecoverable
error — the trap propagates as a wasm trap through the adapter and
on up to the host. The adapter does nothing special; the runtime's
backtrace points at the adapter's dispatch wrapper at the hook-call
site, so an operator can tell from the backtrace alone that the trap
originated in the middleware hook (not in the wrapped target
function).
A middleware whose code is broken fails loudly so the operator
notices. A configurable on-middleware-trap: propagate | log | swallow policy is plausible future work if a concrete use case
justifies it.
One tier per middleware
A given middleware component must implement exactly one tier.
Within that tier, any non-empty subset of the tier's interfaces is
fine (e.g. a tier-1 middleware can export any combination of before /
after / gate), but exporting interfaces from multiple tier
packages is rejected at splice time.
This is by design, not a missing feature. Higher tiers strictly subsume
the capabilities of lower ones — tier 2's on-call already carries the
function name, so a tier-2-aware component never needs tier-1 hooks
too. If you want to combine behaviors (say, observation plus
modification), ship them as separate components and chain them via
inject: [...] in the splice config. That makes the layering visible
at the configuration level rather than hidden inside one component's
exports.
Three outcomes, checked in order:
- Middleware exports the target interface directly: fingerprint- compared against the contract; match wires it in, mismatch is a hard error.
- Middleware exports a
splicer:tierN/*package: wired through the tier adapter (whose signature is the contract). - Neither: can't be wired here; splicer warns and skips the injection.
Composing middleware in a chain
A splice rule's inject: [m1, m2, m3] produces a chain where m1 is
the outermost wrapper (closest to the caller) and m3 is innermost
(closest to the downstream). Tiers 1-3 compose freely in any order —
the resulting behavior is always well-defined, though not always
commutative:
- Tier 1 (name only) never sees or touches values, so its presence is invisible to other middleware. Slots in anywhere; commutes with everything.
- Tier 2 (observe) sees values but doesn't modify them, so its presence doesn't change what flows through to neighbors. Commutes with everything; what it observes depends on what its outer/inner neighbors decided to do.
- Tier 3 (transform) modifies values, so order matters: a tier-3 closer to the caller sees args before any inner transformations are applied, and sees results after they've been applied. Two tier-3s produce different results in different orders. This is the same decorator-chain semantics as Express, Tower, Rack, etc. — well-defined for any order, just not commutative.
Tier 4 is a chain terminator. A tier-4 middleware replaces the
downstream entirely, so anything past it in the chain is unreachable
— no calls flow through. Tier 4 must therefore be the innermost
entry in inject. Splicer warns if it sees middleware listed after a
tier-4 entry (the trailing entries can never fire).
Concrete walk-through with inject: [t1, t2, t3] and a single
handle(req) → resp call:
caller → t1.before("handle") // tier 1: name only
→ t2.on-call("handle", lifted-args) // tier 2: observe
→ t3 lifts args, mutates → args', lowers // tier 3: transform
→ downstream(args') → resp
→ t3 lifts resp, mutates → resp', lowers // tier 3: transform back
→ t2.on-return("handle", lifted-resp') // tier 2: observe post-transform
→ t1.after("handle") // tier 1: name only
→ caller gets resp'
Reorder the same three to [t3, t2, t1] and t2 will observe the
post-transform args on the way in (because t3 is now outside it) and
the pre-back-transform result on the way out — different snapshots,
same overall correctness.