Middleware
Middleware intercepts messages flowing through the client. It follows the
onion model you know from Hono or Koa: each middleware receives a context and
a next(), may transform ctx.data, may short-circuit by not calling
next(), and may be async.
A bare function registers inbound middleware; the object form registers per direction:
client.use((ctx, next) => { ... }); // inboundclient.use({ outbound: attachToken }); // outbound onlyclient.use({ inbound: logIn, outbound: logOut }); // one logical middleware, // both directionsMiddleware never adds client API — that’s the vocabulary boundary between middleware and plugins.
Inbound
Section titled “Inbound”Runs after codec.decode (and after schema validation,
so middleware only ever sees trusted data), before the message event:
client.use((ctx, next) => { console.log("received:", ctx.data); ctx.data = normalize(ctx.data); // handlers see the transformed value return next();});Short-circuiting suppresses the message event — useful for protocol frames
your handlers shouldn’t see. The built-in pingpong middleware does exactly
this: auto-replies to server pings without bubbling them.
import { pingpong } from "durablews";client.use(pingpong);Outbound
Section titled “Outbound”Runs at transmission time — after the queue, before codec.encode. The
flagship use case is auth:
client.use({ outbound: async (ctx, next) => { ctx.data = { ...ctx.data, token: await getFreshToken() }; await next(); }});Transmission-time execution is a durability feature: a message queued across a
30-second reconnect is stamped with a token that is fresh when it actually
goes out, not when you called send(). It also means drop events always
carry the raw value you passed to send() — never a half-transformed one.
Ordering (and its honest cost)
Section titled “Ordering (and its honest cost)”Outbound middleware may be async, and the outbound path is serialized:
messages reach the socket in send() order even while an earlier message’s
middleware awaits. When nothing is in flight and the chain is synchronous,
send() stays fully synchronous — zero overhead.
The flip side: a middleware that delays one message delays everything behind
it (head-of-line). That’s correct for the things middleware is for — pacing
the stream means delaying it; a token refresh blocking sends is what
freshness requires. Policies that want to selectively delay or collapse
specific messages (per-key debounce, batching) inherently want reordering,
which the pipeline refuses. Compose those in front of send() instead:
const sendTyping = debounce((s) => client.send(s), 300);Same composability, different layer — and unrelated messages never wait.
Failure semantics
Section titled “Failure semantics”- Short-circuit (returning without
next()) means deliberately not sent. Nodropevent —dropmeans the library couldn’t deliver something; this is your policy choosing not to. - A throw or rejection surfaces as an
errorevent and skips only that message; everything behind it continues. - Connection drops mid-pipeline (an async middleware was awaiting when
the socket died): if a reconnect is underway the original value is
re-queued ahead of newer sends; otherwise it surfaces as a
drop. Never silently lost. - Heartbeat pings bypass outbound middleware entirely.
Writing middleware once, typing it
Section titled “Writing middleware once, typing it”Middleware is typed by the client’s generics: inbound contexts carry TIn,
outbound contexts carry TOut.
const client = defineClient<ServerMsg, ClientMsg>({ url });
client.use({ outbound: (ctx, next) => { ctx.data; // ClientMsg return next(); }});