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:

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.

TierSee call nameSee typed dataModify dataBypass downstreamStatus
1yesnonopartial (block)supported
2yesyesnonosupported
3yesyesyesnosupported (builtin)
4yesyesyesyessupported (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-tree for 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-sdk strategy traits. Splicer reads the target's WIT, runs wit-bindgen to 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:

  1. Middleware exports the target interface directly: fingerprint- compared against the contract; match wires it in, mismatch is a hard error.
  2. Middleware exports a splicer:tierN/* package: wired through the tier adapter (whose signature is the contract).
  3. 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.