@setu-ts/realtime-backplane-plugin
Cross-replica fan-out for WebSocket rooms and SSE channels.
Why
@setu-ts/websocket-plugin rooms and @setu-ts/sse-plugin channels hold their membership in
ordinary in-process sets. That is correct and fast on a single instance, and invisible to every
other instance — behind a load balancer, ws.room('lobby').broadcast(...) reaches only the clients
that happen to be connected to the replica that ran it.
This plugin registers an IRealtimeBackplane under CAPABILITIES.REALTIME_BACKPLANE. Both of those
plugins resolve that token optionally, so registering this plugin is the whole change: rooms and
channels become cluster-wide, and removing it returns them to in-process behavior with no
application code touched.
Installation
npm install @setu-ts/realtime-backplane-plugin
# Deno: deno add npm:@setu-ts/realtime-backplane-plugin
Usage
Register it before the WebSocket or SSE plugin — its HIGH priority already ensures the
transport is connected before either subscribes.
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { RealtimeBackplanePlugin } from '@setu-ts/realtime-backplane-plugin';
import { WebSocketPlugin } from '@setu-ts/websocket-plugin';
import { SsePlugin } from '@setu-ts/sse-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
RealtimeBackplanePlugin({ transport: 'redis', url: 'redis://localhost:6379' }),
WebSocketPlugin(),
SsePlugin(),
],
});
await app.start({ port: 3000 });
Nothing else changes. ws.room('lobby').broadcast(data) and sse.channel('news').publish(msg) now
reach clients on every replica.
The memory, messaging, and Redis transports expose handlerErrors as the most recent 100 subscriber
errors, oldest first. Delivery continues to other subscribers when a handler throws.
Transports
transport | Crosses processes | Dependencies | Notes |
|---|---|---|---|
'memory' | No | None | The default. A real single-process bus, not a no-op — useful in tests and dev |
'messaging' | Yes | A plugin providing messaging | Reuses every broker the messaging plugin ships; adds no dependency |
'redis' | Yes | ioredis@5.x (lazy) | Redis pub/sub. Opens two connections (see below) |
'custom' | Depends | None | Any IRealtimeBackplane you supply |
'messaging'
RealtimeBackplanePlugin({ transport: 'messaging' });
Publishes over whatever broker is registered under CAPABILITIES.MESSAGING — in-memory, Redis
Streams, RabbitMQ, NATS, or Kafka. Configuring it with no messaging capability registered throws
during register() rather than failing silently per request.
'redis'
RealtimeBackplanePlugin({ transport: 'redis', url: 'redis://localhost:6379' });
// Or inject clients — both are required together:
RealtimeBackplanePlugin({ transport: 'redis', client, subscriber });
Two connections, deliberately: a Redis connection in subscriber mode refuses every command other
than (un)subscribe, so one connection cannot both publish and subscribe. Injecting a client
without a subscriber throws at construction rather than failing at the first publish.
Connection errors of the ioredis clients the transport builds go to the application logger — the
first error of an outage at warn, identical repeats at debug, the recovery at info — instead
of ioredis printing every reconnect failure to console.error. An injected client gets no
listener: it belongs to the caller. Set connectionErrorReporter to route them elsewhere.
node-redis. To use node-redis instead of ioredis, pass two adapters —
client: fromNodeRedis(base) and subscriber: fromNodeRedis(base.duplicate()), where
base = createClient({ url }) from redis — mirroring the two connections the transport builds.
When the backplane starts it connects an injected client that has not connected yet
(status === 'wait', a lazy ioredis client included), so its health reports the publisher’s state
from the start. See
Using node-redis.
Options
| Option | Applies to | Default | Description |
|---|---|---|---|
topic | all but memory | 'setu-ts.realtime' | Broker topic / Redis channel. Every replica must agree on it |
origin | all | a fresh runtime.uuid() | This replica’s identity. Override only to make a test deterministic |
bus | 'memory' | 'default' | Named in-process bus; separate names stay isolated |
url | 'redis' | — | Connection URL, used only on the lazy-load path |
client / subscriber | 'redis' | — | Injected client pair; required together |
module | 'redis' | — | An ioredis-shaped module, for testing without the real driver |
commandTimeoutMs | 'redis' | 15000 | Per-command timeout on the two connections the lazy path builds; 0 disables. Must be 0–2147483647 (above that the timer overflows to 1 ms). See Limitations |
connectionErrorReporter | 'redis' | logger-backed | Receives the two built connections’ 'error'/'ready' events (see above). Ignored for an injected pair |
instance | 'custom' | — | The transport to register, used as-is |
localNotice | 'memory' | true | Logs one info line at registration when the transport is the process-local 'memory', naming 'redis'/'messaging'. false suppresses it |
diagnostics | all but 'custom' | omitted | Opt-in realtime observations (M98l); see below |
Diagnostics (M98l)
Subscriber failures, both synchronous throws and returned promise rejections, are isolated and
reported through the application logger. The first failure per handler warns, further failures debug
with a running repeats count, and successful completion logs one recovery at info and rearms the
warning. Messages use common’s describeError; metadata includes the frame kind and name, never its
data. The logger is read at call time and sink failures are swallowed.
Set onHandlerError: (error, frame) => { ... } on the memory, messaging or Redis options to receive
every failure instead of default logging. A throwing hook is swallowed. Direct transport
constructors accept an optional trailing IRealtimeHandlerErrorReporter; with no reporter they
retain their bounded history only. handlerErrors keeps the most recent 100 failures in either
mode, including asynchronous rejections.
RealtimeBackplanePlugin({ transport: 'redis', url, diagnostics: { enabled: true, alias: 'fanout' } })
counts, for the local diagnostics connector’s GET /v1/realtime (@setu-ts/diagnostics-plugin),
how many publications resolved or rejected (with the duration of the last one) and how many arriving
frames reached the local handlers. A publication’s success means the transport’s publish()
resolved — never that a peer received it — and a receive is counted only after the transport’s own
frame-shape and own-origin filters admitted the frame; a receive where any local handler threw
synchronously is failed. An async outcome is unknown when this count is recorded. A backplane
reports no gauges. No frame, room or channel name, payload, exceptId or origin is ever captured. A
'custom' transport is never observed and its options accept no diagnostics field.
Without the option the plugin registers an inert disabled source and nothing is attached to the
transport. Observation never changes a result: a rejected publish still rejects with the same
reason, and a failing clock latches the source collection-failed instead. Enable only on an
approved development dataset: counts aggregate every tenant using the instance.
Limitations
Room.sizeandSseChannel.sizereport LOCAL membership. A cluster-wide count is inherently asynchronous (a scatter-gather across replicas), so it cannot satisfy the synchronous committedsizegetter; it wants a separate async method, which a later milestone owns.
RoomBroadcastOptions.except is not a limitation: it is honored on every replica. Connection
IDs come from runtime.uuid() and are globally unique, so the frame carries the excluded ID and
each replica skips the matching member.
Delivery is at-most-once and inherits the transport’s guarantees — and the guarantee has a shape
worth knowing. On 'redis', ioredis’s own defaults govern what a partition does:
- A short partition (up to roughly 11 seconds): frames are buffered and delivered LATE, not
dropped. The publisher connection is created with ioredis’s default
enableOfflineQueue: true, so publishes issued while disconnected are held and flushed on reconnect. The receiving side sees stale ops seconds after the fact — measured at ~5.9 s in this framework’s two-replica exercise — which an application told “frames during a partition are missed” will not defend against. A collaborative board should treat out-of-order late arrivals as possible. - A longer partition: the buffered commands reject when ioredis’s
maxRetriesPerRequestbudget (default 20 retries, ~11 s on the default backoff) exhausts, and both consumers log onewarnper dropped frame. - A silent connection: the publish rejects after
commandTimeoutMs(default 15 s). A host that stops answering without closing the socket — paused, or partitioned with no reset — triggers no reconnect, so no retry budget applies; before this bound such a publish never settled, so neither consumer logged it and an observed publish was never recorded. The default sits above the ~11 s retry budget, so the two partition cases above behave as described; lowering it below that budget makes a dropped connection reject sooner too. - A publish before
connect()or afterclose()rejects rather than resolving, so a frame sent nowhere reads as a failure. Earlier releases resolved it without sending. close()during an outage still releases both connections. Each step runs even when an earlier one fails, a connection whose QUIT fails is force-closed throughdisconnect()(without it, ioredis reconnects and re-subscribes once an unreachable server returns), and the first failure is rethrown afterwards, so a shutdown during an outage is reported rather than leaving connections reconnecting behind it. Against a silent server each step waits out the command timeout, soclose()can take about three of them (≈45 s at the default); lowercommandTimeoutMsif that exceeds your shutdown grace period.- A timed-out publish may still be sent. A publish rejected by the command timeout can already
be written to, or queued for, the connection, and reach Redis afterwards — measured at the default
against a paused server, and with a lowered
commandTimeoutMsagainst a stopped one. A caller that retries a rejected publish may therefore deliver that frame twice; consumers that cannot tolerate a duplicate need their own de-duplication. Against a server that refuses connections, the default’s retry budget rejects the queued publish first and it is discarded.
Frames are never persisted or replayed beyond that buffer. commandTimeoutMs is the only ioredis
setting reachable through this plugin’s options, and applies only on the lazy path — an application
that needs different behaviour, or injects its own client/subscriber pair, configures those
clients directly.
Documentation
License
MIT
Health indicator
The realtime-backplane indicator reports up, including fan-out outages. Local request delivery
still works. data.reachable: false exposes an unreachable backend; a transport without a probe
reports 'unknown'. /ready therefore remains 200 for this shared dependency condition.
data reports { transport, origin, reachable }, where reachable is true, false, or
'unknown'.
Bounded probe (M119b, V10-20). The reachability check is now cached for 5 s and bounded at 2 s.
A check that does not answer within the bound reports reachable: 'unknown' and the status stays
up; previously the unbounded Redis PING outlived the health indicator’s 5 s deadline and read
down, taking the replica out of rotation for a paused Redis. A transport without a probe still
reports 'unknown'.
Exports
| Export | Kind |
|---|---|
adaptRedisModule | function |
createBackplane | function |
decodeFrameData | function |
encodeFrameData | function |
isRealtimeFrame | function |
loadRedisModule | function |
RealtimeBackplanePlugin | function |
MemoryBackplane | class |
MessagingBackplane | class |
RedisBackplane | class |
RedisModuleError | class |
CAPABILITIES | const |
DEFAULT_REDIS_COMMAND_TIMEOUT_MS | const |
DEFAULT_TOPIC | const |
BackplaneCommonOptions | interface |
CustomBackplaneOptions | interface |
EncodedPayload | interface |
IRealtimeBackplane | interface |
IRedisBackplaneClient | interface |
IRedisModule | interface |
MemoryBackplaneOptions | interface |
MessagingBackplaneOptions | interface |
RealtimeDiagnosticsOptions | interface |
RealtimeFrame | interface |
RedisBackplaneOptions | interface |
RealtimeBackplanePluginOptions | type |
RealtimeFrameHandler | type |
RealtimeFrameKind | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
API reference
RealtimeBackplanePlugin() (@setu-ts/realtime-backplane-plugin)
Provides cross-replica fan-out for WebSocket rooms and SSE channels. Registers an
IRealtimeBackplane under CAPABILITIES.REALTIME_BACKPLANE ('realtime-backplane'). Added in
Milestone 47.
Rooms and channels hold membership in in-process sets, so behind a load balancer a broadcast reaches
only the clients connected to the replica that issued it. WebSocketPlugin and SsePlugin resolve
this token optionally, so registering this plugin is the entire change; removing it restores
in-process behavior with no application code touched.
Registration
import { RealtimeBackplanePlugin } from '@setu-ts/realtime-backplane-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
RealtimeBackplanePlugin({ transport: 'redis', url: 'redis://localhost:6379' }),
WebSocketPlugin(),
SsePlugin(),
],
});
Its priority is PLUGIN_PRIORITY.HIGH, so the transport is connected before either consumer
registers and subscribes.
Options
Discriminated on transport.
BackplaneCommonOptions.onHandlerError?: (error: unknown, frame: RealtimeFrame) => void (0.11.0)
replaces default subscriber logging for every sync throw and async rejection on built-in transports.
Hook failures are swallowed. Without it, a call-time logger warns on the first failure per handler,
debugs repeats with a count and logs recovery once at info. Descriptions use describeError, with
frame kind and name metadata but no frame data. The latest 100 failures are retained in
handlerErrors in either mode. Direct transport constructors accept an optional trailing
IRealtimeHandlerErrorReporter; omitted, they retain history only. Custom transports are used as
supplied and own their reporting.
| Option | Applies to | Default | Description |
|---|---|---|---|
transport | all | 'memory' | 'memory' | 'messaging' | 'redis' | 'custom' |
topic | all but memory | 'setu-ts.realtime' | Broker topic / Redis channel. Every replica must agree on it |
origin | all | a fresh runtime.uuid() | This replica’s identity. Override only to make a test deterministic |
bus | 'memory' | 'default' | Named in-process bus; separate names stay isolated |
url | 'redis' | — | Connection URL, read only on the lazy ioredis path |
client / subscriber | 'redis' | — | Injected client pair. Required together — see Notes |
module | 'redis' | — | An ioredis-shaped module, for testing without the real driver |
commandTimeoutMs | 'redis' | 15000 | Per-command ioredis timeout on the two connections the lazy path builds, so a publish on an open-but-silent connection rejects instead of hanging. 0 disables; a value outside 0–2147483647 (the largest timer delay — beyond it the timer overflows to 1 ms and every command times out) throws RangeError at construction. Ignored for an injected pair |
connectionErrorReporter | 'redis' | logger-backed | Receives the 'error'/'ready' events of the two connections the lazy path builds. The plugin defaults it to a createConnectionErrorReporter over the application logger (first error warn, repeats debug, recovery info); supply one to route them elsewhere. Without a listener ioredis prints every reconnect failure to console.error. Ignored for an injected pair, which belongs to the caller |
instance | 'custom' | — | The IRealtimeBackplane to register, used as-is |
localNotice | 'memory' | true | Logs one info line at register() when the resolved transport is the process-local 'memory', naming 'redis'/'messaging' as the cross-process choices. false suppresses it, matching the consumers’ scalingNotice opt-out shape |
diagnostics | all but 'custom' | omitted | M98l opt-in: { enabled: true, alias } counts publications (resolved or rejected, with the last duration) and arriving frames that reached local handlers, for the diagnostics connector’s GET /v1/realtime. A 'custom' transport is never observed. Absent, the plugin registers a disabled source |
Registering the plugin bare is not a scaling fix: the default 'memory' transport is a real bus
but a single-process one, and before the localNotice existed a bare registration also silenced the
consumers’ startup notices without fanning anything out. The notice now comes from the plugin that
knows its own transport.
The memory, messaging, and Redis transports retain the most recent 100 subscriber errors in
handlerErrors, oldest first.
Transports
transport | Crosses processes | Dependencies | Notes |
|---|---|---|---|
'memory' | No | None | The default, and a real single-process bus rather than a no-op |
'messaging' | Yes | A plugin providing messaging | Reuses any registered messaging broker; adds no dependency |
'redis' | Yes | ioredis (lazy) | Redis pub/sub, over two connections |
'custom' | Depends | None | Any IRealtimeBackplane |
Exports
| Symbol | Kind | Description |
|---|---|---|
RealtimeBackplanePlugin | function | Plugin factory |
createBackplane | function | Transport factory dispatching on the transport discriminant |
MemoryBackplane | class | In-process transport |
MessagingBackplane | class | Transport over CAPABILITIES.MESSAGING |
RedisBackplane | class | Redis pub/sub transport |
isRealtimeFrame | function | Guard narrowing arriving broker traffic to a RealtimeFrame |
adaptRedisModule | function | Narrows an ioredis module to IRedisModule |
loadRedisModule | function | Real lazy import('ioredis') |
RedisModuleError | class | Thrown when ioredis cannot be loaded or recognized |
DEFAULT_TOPIC | const | 'setu-ts.realtime' |
DEFAULT_REDIS_COMMAND_TIMEOUT_MS | const | 15000, the default commandTimeoutMs |
IRedisBackplaneClient | interface | Structural facade for an injected Redis client; optional connect?() (M117) is called at start for an injected client whose status is 'wait' |
IRedisModule | interface | Structural facade for the ioredis module |
RealtimeBackplanePluginOptions | type | Discriminated union of the four transport arms |
BackplaneCommonOptions | interface | topic, origin, localNotice and diagnostics, shared by every arm but 'custom' |
MemoryBackplaneOptions | interface | The 'memory' arm |
MessagingBackplaneOptions | interface | The 'messaging' arm |
RedisBackplaneOptions | interface | The 'redis' arm |
CustomBackplaneOptions | interface | The 'custom' arm |
IRealtimeBackplane, RealtimeFrame, RealtimeFrameHandler, RealtimeFrameKind, EncodedPayload, RealtimeDiagnosticsOptions | type (re-export) | From @setu-ts/common |
encodeFrameData, decodeFrameData, CAPABILITIES | value (re-export) | From @setu-ts/common |
Notes
- Loop prevention is an origin stamp. Every instance owns an
origin(aruntime.uuid()by default); a subscriber drops frames carrying its own. An arriving frame is delivered through the consumer’s local-only path and never re-published, so a broadcast is delivered exactly once per replica. - One topic carries both kinds.
RealtimeFrame.kindis'ws-room'or'sse-channel', and each consumer ignores the other — a room and a channel may legitimately share a name. - Redis needs two connections. A Redis connection in subscriber mode refuses every command other
than (un)subscribe, so one connection cannot both publish and subscribe. That is a property of the
protocol, not of
ioredis. Injecting aclientwithout asubscriberthrows at construction rather than failing at the first publish; the lazy path builds both fromurl. transport: 'messaging'with no messaging capability throws duringregister(), rather than failing silently per request.- A remote frame never creates a room or channel. It is delivered only to one that already exists locally, so a cluster-wide namespace cannot grow a replica’s maps without bound.
- Binary WebSocket frames are base64-encoded for the wire (
encodeFrameData/decodeFrameData, in@setu-ts/commonbecause three packages need the identical shape). AnSseMessageis already JSON-serializable and travels as its JSON encoding. - Delivery is at-most-once and inherits the transport’s guarantees. Frames are not persisted or
replayed. On
'redis'a SHORT partition buffers rather than drops: ioredis’s defaultenableOfflineQueue: trueholds publishes issued while disconnected and flushes them on reconnect, so frames arrive LATE (measured ~6 s) until themaxRetriesPerRequestbudget (default 20, ~11 s) exhausts and the buffered commands reject with awarnper frame. A connection that stays open while the server answers nothing (paused, or partitioned with no reset) triggers no reconnect; its publish rejects aftercommandTimeoutMs(default 15 s, above that budget) rather than never settling. A publish beforeconnect()or afterclose()rejects rather than resolving. BeyondcommandTimeoutMs, ioredis defaults are not configurable through this plugin; inject aclient/subscriberpair to change them. RoomBroadcastOptions.exceptis honored cluster-wide. It names a live connection object, which means nothing in another process — but connection IDs come fromruntime.uuid()and are therefore globally unique, soRealtimeFrame.exceptIdcarries the ID and every replica skips the matching member. Excluding a peer connected to a different replica works for the same reason.Room.size/SseChannel.sizeremain local. A cluster-wide count is inherently asynchronous (a scatter-gather across replicas), so it cannot satisfy the synchronous committedsizegetter; exposing one is a contract decision — a separate async method — that a later milestone owns.
Health status
The realtime-backplane indicator reports up, including fan-out outages. Local request delivery
still works. data.reachable: false exposes an unreachable backend; a transport without a probe
reports 'unknown'. /ready therefore remains 200 for this shared dependency condition.
data reports { transport, origin, reachable }, where reachable is true, false, or
'unknown'.