@dokebi/websocket-hub
v0.4.2
Published
WebSocket hub for Dokebi: connection roster, rooms, broadcast and kick across workers and servers.
Downloads
637
Maintainers
Readme
@dokebi/websocket-hub
Node >=24, version 0.4.1 (unreleased). An optional plugin of Dokebi @dokebi/core ^0.4.0: a TCP hub that replaces the former @dokebi/sync. AI agents start with AI.md.
There are two modes (D-0029).
mode: local(default): a hub for one process. It handles connection lists, rooms, broadcasts and kicks in memory, without Redis or NATS.- The API and broadcast semantics are those of cluster mode (at most once, "Broadcast" below). A broadcast is done once it is in the event queue websocket reads. When half of that queue is still unread it is refused with
BUSY(leaving room for presence and kicks). - There is no failover.
- In
node:clusterworkers the workers cannot share connections, so start is refused withLOCAL_IN_CLUSTER. Allow it withallowInCluster: trueonly on purpose.
- The API and broadcast semantics are those of cluster mode (at most once, "Broadcast" below). A broadcast is done once it is in the event queue websocket reads. When half of that queue is still unread it is refused with
mode: cluster: connections are shared by processes and servers.- Every WebSocket worker is a hub candidate, and a Redis lease elects one. TCP carries user registrations, snapshots, broadcast and targeted messages and kicks, and Redis keeps the connection list.
namespace,serverId,redisConnectionandsecretare required.- NATS (
natsConnection) is optional. With it, leader changes are announced at once; without it other servers notice through Redis polling (pollIntervalMs). - When the hub dies, the real WebSockets of healthy servers stay and reconnect to the new hub.
- Install check: the install module's
checklooks at the mode and reports the required plugins: Redis in cluster mode, and NATS too when a NATS connection is set. whenReady(timeoutMs)(D-0034): true once ready, false when the time passes or it closes. websocket uses it to wait out short reconnects when accepting upgrades. It logs why the leader connection dropped (Hub connection dropped), failures while recovery continues (Hub still recovering) and the recovery time (Hub recovered). A leader failover (the leader died) is a warn,Hub lost the leader connection; failing overandHub failed over to a new leaderwith the attempt count and the first and last error; it becomes an error only past2 × leaseMs + pollIntervalMs(D-0097 §2).- presence events: they come as the full list per owner (server) when connections come and go. When a peer reconnects or the leader changes, owners that differ from the snapshot and owners that disappeared are announced again as presence events, so consumers never miss connections that left meanwhile.
schemaVersion: 1
options:
dokebi-redis:
connections:
primary:
url: redis://redis.internal:6379
dokebi-nats:
connections:
primary:
servers: [nats://nats.internal:4222]
dokebi-websocket-hub:
mode: cluster
namespace: my-app
serverId: web-a
host: 0.0.0.0
advertiseHost: web-a.internal
redisConnection: primary
natsConnection: primary
secret: replace-with-your-random-secret-at-least-32-bytesserverId/advertiseHost are per server; namespace, secret and shared stores must match across the area; limits may differ between servers (a rolling deploy that changes them, D-0136). Each worker opens its own TCP port (port default 0), which must be reachable from other servers. The TCP has no built-in TLS, so use a trusted private network or an encrypted tunnel.
import { Application, loadConfig } from '@dokebi/core';
import { createPlugin } from '@dokebi/websocket-hub';
const config = await loadConfig('./dokebi.yaml');
const app = new Application({ config: config.options });
const plugin = createPlugin({ app: app.view });
app.register(plugin);
await app.start();
const hub = app.get(plugin);
const connection = await hub.register({tenant:'demo',member:'alice',connection:'socket-1'});
// With the websocket plugin, that plugin is the sole consumer instead.
const consumer = (async () => {
for await (const event of hub.events()) {
// the message event is the whole delivery: write event.data to this server's event.connections (no ACK).
if (event.kind === 'message') writeToSockets(event.connections, event.data);
}
})();
const snapshot = await hub.list({tenant:'demo'});
const receipt = await hub.broadcast({tenant:'demo',member:'alice'}, new TextEncoder().encode('hello'));
// receipt: { id, recipients } — connections matching when sent (not a confirmation of receipt)
await connection.close();
await app.stop();
await consumer;HubConnection: identity (ownerEpoch/connection), join(room), leave(room), updateMetadata(record), close(). HubApi: state, role, ownerEpoch, leader, register, list, broadcast, kick, events, whenReady, status, nonces. A query is the AND of a required tenant and optional room/member/connection. events is the single async iterator of presence, message and kick events. kick (default code 4403) is a TCP request that returns { authorityEpoch, id }.
| Key | Type | Default | Description |
|---|---|---|---|
| leaseMs | integer ms | 3000 | Leader lease |
| pollIntervalMs | integer ms | 500 | Polling without NATS |
| ownerTtlMs | integer ms | 15000 | Server registration expiry |
| startupTimeoutMs | integer ms | 15000 | Startup wait |
| maxOwners | integer | 64 | Servers |
| maxConnections | integer | 128 | Connections per worker |
| maxTotalConnections | integer | 8192 | All connections |
| maxFrameBytes | integer | 1048576 | Hub TCP frame |
| maxPayloadBytes | integer | 65536 | Message |
| maxPending | integer | 128 | Broadcasts in progress |
| maxPendingBytes | integer | 4194304 | Queue bytes |
A full snapshot also stays within the frame limit. Check the option schema. It requires pollIntervalMs × 3 ≤ leaseMs and ownerTtlMs ≥ leaseMs × 3. 0.4.0 renamed pollMs → pollIntervalMs, startupMs → startupTimeoutMs and queueBytes → maxPendingBytes (D-0071); the old names still work in 0.4.0 with an OPTION_DEPRECATED warning.
Broadcast: at most once (0.3.0, D-0057)
- Success = sent out. In cluster mode the leader picks the connections matching right now, writes one message frame to each server holding them, then gives the sending server the receipt
{ id, recipients }. local is done once it is in the event queue. There is no client ACK, resend, deduplication, completion record or deadline wait. Broadcasts never go through Redis and never stay in leader memory. recipientsof the receipt are the identifiers of the connections matching when it was sent (the SHA-256 of tenant, member and clientId, or of owner and connection without a clientId). It is not a confirmation of receipt. Connections that were reconnecting, that left before the frame was written or that were closed with 1013 for being slow do not get it, and connections arriving later do not get it.- Failures are reported at once and never resent.
RECOVERING: not connected to the leader (leader change included) — not sent.OUTCOME_UNKNOWN: the connection dropped after sending the request, before the answer — it may have been sent.BUSY: broadcasts in progress reachedmaxPending. The caller decides whether to send again. - App guideline: never leave work that must not be missed (order state, payment results) to a broadcast. Store it in the DB and let the page read the latest state from the server when it reconnects. Use broadcasts as "it changed, read again" notices. Work that must happen goes to @dokebi/job.
- Removed options:
deliveryTimeoutMs,retryMs,maxDeliveries,deliveryBytes. Start still works when they remain; each key logs warnOPTION_REMOVEDonce and is ignored. The schema keeps them as deprecated (removed in the next version). - Version between hub servers: hello carries
protocol: 2. The leader refuses servers of another version withPROTOCOL_MISMATCH(warn), and the refused server logs an error with both versions and retries. A 0.3 server connected to a 0.2 leader is cut too. When versions mix during a rolling restart, old servers cannot join the new leader, so upgrade every server of the namespace to the same version. The configuration fingerprint stays the same as 0.2 so the version check tells them apart (against a 0.1.x Redis outbox it is stillCONFIG_MISMATCH). clientIdis an identifier per authenticated device or tab. The newest registration of a(tenant, member, clientId)wins: the older connection, on any server, is closed with 4409Replaced(a tab that reconnected elsewhere while its old socket was half open keeps working; a replaced tab must not reconnect with the same clientId). A server that reconnects to the leader is never refused for a clientId another server took meanwhile: those connections of it are closed instead.
Operational status and replay defense (D-0038)
status(): returns this process (mode,state,role,serverId) together with the hub as Redis sees it (leaseState,leader,leaseRemainingMs,servers,connections). The calculation is one pure function,hubStatus. Unknown values are left out, not filled with 0 — withleaseState: 'unreachable'(Redis does not answer) there are noserversorconnections.nonemeans no leader (failover in progress, or no server is up).dokebi-hub-status --config dokebi.yaml [--json]: answers the same question from outside the app. It starts only the Redis plugin and reads the leader and owner records, without joining the election or registering. When Redis does not answer it exits with 1. In local mode it says the state lives only inside the process.nonces(): the one-time nonce store for sealed frames (passed as is tononcesof @dokebi/sealSealTerminator; only the shape matches and seal is never imported). cluster mode uses RedisSET NX PX(shared: true), so a frame replayed to another server is refused too. local mode keeps it in the process (shared: false) and warnsHUB_NONCES_NOT_SHAREDonce when used in a cluster worker. The local store keeps at most 100000 live nonces and fails closed (D-0075): it never drops a live nonce to make room, so when it is full of live nonces (more than about 830 sealed frames or bodies per second for 2 × drift = 120 s) new ones are refused withNONCE_STORE_FULL(retryable: web 503SEAL_BUSY, websocket 1013), with a warnNONCE_STORE_FULLonce per 10 seconds. This keeps replay protection at the cost of availability under such a flood; use cluster mode (Redis) for more.
Factory and dependencies (plugin contract v2)
The factory input is { app, options? }; app is required. Without options it reads app.config['dokebi-websocket-hub']. It takes no dependencies record and no Redis or NATS instance inputs (D-0028).
- Redis and NATS are wired through the
dokebi-redisanddokebi-natsprovider descriptors. A plugin registered under the same name is reused; otherwise it is created from the@dokebi/redisor@dokebi/natspackage withapp.config. To choose one, register it first, e.g.app.register(createRedis({ app: app.view })). @dokebi/redisand@dokebi/natsarepeerDependencies. The app installs both packages.- Other plugins put
import { dependency } from '@dokebi/websocket-hub/dependency'(the provider descriptor, the same object as the package entry'sdependency) inmetadata.dependenciesand get the API withcontext.getDependency(dependency). Plugins for which the hub is optional keep their ownoptionalDependency. - The Redis authority script is created once per store with
redis.script()and runs asEVALSHA. Only when the server lacks the script (NOSCRIPT) isEVALsent once more.
With the CLI, prepare the YAML and register in the order npx dokebi add @dokebi/redis → @dokebi/nats → @dokebi/websocket-hub. The manifest is schemaVersion 2, and its install module (dist/install.js) only reports required plugins; it creates no files. Installing makes no server connections. The hub has no drain hook and closes its resources in cleanup after the app drain finished.
Example 18 in the repository covers the public API and a local TCP baseline; example 23 verifies two Docker servers, four workers, hub and server faults and a real DB drain. That does not mean a full port of XFramework's extra media and nonce features or full operational certification.
Configuration fingerprint (2026-10-14)
Servers of one namespace need the same secret (the shared configuration fingerprint in Redis). While a server with another secret is alive, a new server fails to start at once with CONFIG_MISMATCH and logs an error (code: CONFIG_MISMATCH). Once every leader and owner with the old secret is gone (a full restart), the new one replaces the fingerprint. Since 0.4.2 (D-0136) lease, TTL and size limits may differ between servers: each reads the others' frames against the protocol maxima and applies its own limits to its own clients, the leader's to the shared presence. The full fingerprint of 0.4.1 is still written once (never overwritten) so that running older servers keep matching during the upgrade; upgrade first, then change limits. So rotating the secret is a full stop and start, without deleting Redis keys (ISSUE-0005 PG-16).
Shutdown cleanup and startup logs (2026-10-14)
- On a normal shutdown a node deletes its own owner record from Redis directly (
leave: without the leader, only for the same session). Even when everything stops at once, records do not stay untilownerTtlMs, so a restart right after a settings change works (PG-21). - Retries during startup (such as connecting to a leader TCP that is not open yet) log no warnings. Past the startup deadline (
startupTimeoutMs) the error logSTARTUP_TIMEOUTrecords the last error code (lastError). Fault warnings after start carry acode(PG-19).
0.4.0 shared base (D-0063)
- The options are one declaration in
src/options.tsthat is both the parser andoptions.schema.json(generated at build time;npm run check:optionscompares bytes). The connection name and host rules and defaults that only the hand parser kept are in the schema now, andsecretcounts characters and bytes together. Core logs the warnOPTION_REMOVEDfor removed options. HubErroris aDokebiError(plugin,fixfor codes an operator fixes). Log helpers and repeat suppression use corediagnostics(describeError,Repeats).- The cluster worker check uses core
context.runtime.roleinstead ofnode:cluster(LOCAL_IN_CLUSTER,HUB_NONCES_NOT_SHARED). The cluster error code wasHUB_LOCAL_IN_CLUSTERbefore 0.4.0 (D-0071); the error log keeps that code. - The hub index (D-0068 §3: per tenant, member, room, owner and recipient key, so selection walks the smallest matching set) and the delivery semantics are unchanged. Possible future extension hooks are listed in D-0064 §9 (there is no extension class yet).
