npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dokebi/websocket-hub

v0.4.2

Published

WebSocket hub for Dokebi: connection roster, rooms, broadcast and kick across workers and servers.

Downloads

637

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:cluster workers the workers cannot share connections, so start is refused with LOCAL_IN_CLUSTER. Allow it with allowInCluster: true only on purpose.
  • 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, redisConnection and secret are 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 check looks 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 over and Hub failed over to a new leader with the attempt count and the first and last error; it becomes an error only past 2 × 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-bytes

serverId/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.
  • recipients of 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 reached maxPending. 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 warn OPTION_REMOVED once 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 with PROTOCOL_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 still CONFIG_MISMATCH).
  • clientId is 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 4409 Replaced (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 — with leaseState: 'unreachable' (Redis does not answer) there are no servers or connections. none means 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 to nonces of @dokebi/seal SealTerminator; only the shape matches and seal is never imported). cluster mode uses Redis SET NX PX (shared: true), so a frame replayed to another server is refused too. local mode keeps it in the process (shared: false) and warns HUB_NONCES_NOT_SHARED once 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 with NONCE_STORE_FULL (retryable: web 503 SEAL_BUSY, websocket 1013), with a warn NONCE_STORE_FULL once 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-redis and dokebi-nats provider descriptors. A plugin registered under the same name is reused; otherwise it is created from the @dokebi/redis or @dokebi/nats package with app.config. To choose one, register it first, e.g. app.register(createRedis({ app: app.view })).
  • @dokebi/redis and @dokebi/nats are peerDependencies. 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's dependency) in metadata.dependencies and get the API with context.getDependency(dependency). Plugins for which the hub is optional keep their own optionalDependency.
  • The Redis authority script is created once per store with redis.script() and runs as EVALSHA. Only when the server lacks the script (NOSCRIPT) is EVAL sent 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 until ownerTtlMs, 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 log STARTUP_TIMEOUT records the last error code (lastError). Fault warnings after start carry a code (PG-19).

0.4.0 shared base (D-0063)

  • The options are one declaration in src/options.ts that is both the parser and options.schema.json (generated at build time; npm run check:options compares bytes). The connection name and host rules and defaults that only the hand parser kept are in the schema now, and secret counts characters and bytes together. Core logs the warn OPTION_REMOVED for removed options.
  • HubError is a DokebiError (plugin, fix for codes an operator fixes). Log helpers and repeat suppression use core diagnostics (describeError, Repeats).
  • The cluster worker check uses core context.runtime.role instead of node:cluster (LOCAL_IN_CLUSTER, HUB_NONCES_NOT_SHARED). The cluster error code was HUB_LOCAL_IN_CLUSTER before 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).