Middleware
Middleware sees both directions and can observe, rewrite, or drop a frame.
const remove = client.use((frame) => {
console.debug(frame.direction, frame.eventId, frame.payload);
if (frame.direction === "outbound" && isRateLimited(frame.eventId)) {
return false; // drop it
}
if (frame.direction === "outbound") {
return { payload: { ...(frame.payload as object), ts: Date.now() } };
}
});Returning undefined passes the frame through, false drops it, and
{ payload } replaces it. Rewrites happen before validation, so a
middleware can't smuggle a payload past the contract. A middleware that throws
is logged and skipped — it never takes the frame down with it.
Instrumentation & overrides
The seam higher layers build on, mirroring typefetch's client.instrument().
Attaching a hook is the only thing that turns event construction on — with no
hook, the path is identical to the un-instrumented one.
const detach = client.instrument({
on(event) {
// "connect" | "disconnect" | "connect_error"
// "outbound" | "ack" | "inbound" | "dropped" | "frame_error"
timeline.push(event);
},
resolveOverride(eventId, payload) {
if (eventId === "chat.sendMessage") {
return { latencyMs: 800, ack: { id: "mocked", sentAt: Date.now() } };
}
},
});outbound and its ack/frame_error share a frameId, so a panel can pair
them. Overrides let a devtools panel change one frame without touching the
contract:
| Field | Effect |
|---|---|
drop | Discard the frame. An emit awaiting an ack then times out, as a lost packet would. |
latencyMs | Delay the frame. |
payload | Replace the payload (value or deriving function). |
ack | Answer locally, bypassing the network. Still validated. |
error | Force a failure. |
request / response | Swap a schema at runtime to test a structural change. |
The first hook that returns an override wins for that frame.