Skip to content

WebSocket - Full Reference

Exhaustive reference for WebSocketServerHelper and WebSocketEmitter. For a readable introduction and the most common tasks, start with the WebSocket overview.

Files:

Quick Reference

ClassExtendsRole
WebSocketServerHelperBaseHelperBun-native WebSocket server with auth, rooms, heartbeat, Redis Pub/Sub scaling
WebSocketEmitterBaseHelperPublish messages to WebSocket clients from any process via Redis

Import Reference

typescript
import { WebSocketServerHelper, WebSocketEmitter } from '@venizia/ignis-helpers';

import type {
  IWebSocketServerOptions,
  IWebSocketEmitterOptions,
  IWebSocketClient,
  IWebSocketData,
  IWebSocketMessage,
  IRedisSocketMessage,
  IWebSocket,
  IBunServer,
  IBunWebSocketConfig,
  IBunWebSocketHandler,
  TWebSocketAuthenticateFn,
  TWebSocketValidateRoomFn,
  TWebSocketClientConnectedFn,
  TWebSocketClientDisconnectedFn,
  TWebSocketMessageHandler,
  TWebSocketOutboundTransformer,
  TWebSocketHandshakeFn,
  TWebSocketClientState,
  TWebSocketEvent,
  TWebSocketMessageType,
} from '@venizia/ignis-helpers';

import {
  WebSocketEvents,
  WebSocketChannels,
  WebSocketDefaults,
  WebSocketMessageTypes,
  WebSocketClientStates,
} from '@venizia/ignis-helpers';

Architecture

Source -> server/helper.ts, emitter/helper.ts

                     WebSocketServerHelper
                    +---------------------------------------------------+
                    |  constructor(opts)                                |
                    |    |-- identifier, path, serverId (UUID)          |
                    |    |-- Store callbacks (auth, rooms, messages)    |
                    |    |-- Apply defaults (rooms, timeouts)           |
                    |    +-- initRedisClients(redisConnection)          |
                    |          +-- redisPub = duplicateClient()         |
                    |          +-- redisSub = duplicateClient()         |
                    |                                                   |
                    |  configure()  [async]                             |
                    |    |-- Connect Redis clients (if lazyConnect)     |
                    |    |-- await Redis ready (pub + sub)              |
                    |    |-- setupRedisSubscriptions()                  |
                    |    |     |-- subscribe(ws:broadcast)              |
                    |    |     |-- psubscribe(ws:room:*)                |
                    |    |     |-- psubscribe(ws:client:*)              |
                    |    |     +-- psubscribe(ws:user:*)                |
                    |    +-- startHeartbeatTimer()                      |
                    |                                                   |
                    |  getBunWebSocketHandler()                         |
                    |    +-- Returns { open, message, close, drain,     |
                    |         ...serverOptions }                        |
                    +---------------------------------------------------+

                     WebSocketEmitter
                    +---------------------------------------------------+
                    |  constructor(opts)                                |
                    |    +-- redisPub = duplicateClient()                |
                    |                                                   |
                    |  configure()  [async]                             |
                    |    +-- await Redis ready                          |
                    |                                                   |
                    |  toClient / toUser / toRoom / broadcast           |
                    |    +-- publish(channel, IRedisSocketMessage)      |
                    +---------------------------------------------------+

Client connection lifecycle

Client connects via WebSocket upgrade
  |
  +-- onClientConnect({ clientId, socket })
  |     +-- Create IWebSocketClient entry (state: UNAUTHORIZED)
  |     +-- Subscribe to clientId topic (Bun native pub/sub)
  |     +-- Start auth timeout (authTimeout ms)
  |
  +-- Client sends { event: 'authenticate', data: { ... } }
  |     +-- handleAuthenticate()
  |           +-- Set state: AUTHENTICATING
  |           +-- Extended timeout (authTimeout * 3) for async auth
  |           +-- Call authenticateFn(data)
  |                 +-- Success:
  |                 |     +-- Set state: AUTHENTICATED
  |                 |     +-- [requireEncryption?] -> handshakeFn() -> enableClientEncryption()
  |                 |     +-- Index by userId
  |                 |     +-- Subscribe to broadcast topic (unless encrypted)
  |                 |     +-- Join clientId room + defaultRooms
  |                 |     +-- Send 'connected' event
  |                 |     +-- Call clientConnectedFn()
  |                 +-- Failure (null/false or throw):
  |                       +-- Send 'error' event
  |                       +-- Close with code 4003
  |
  +-- Auth timeout expires (still UNAUTHORIZED or AUTHENTICATING)
  |     +-- Close with code 4001
  |
  +-- Heartbeat sweep (every heartbeatInterval)
  |     +-- If now - lastActivity > heartbeatTimeout (AUTHENTICATED clients only)
  |           +-- Close with code 4002
  |
  +-- Client disconnects
        +-- onClientDisconnect()
              +-- Clear auth timer
              +-- Remove from user index
              +-- Remove from all rooms
              +-- Remove from clients map
              +-- Call clientDisconnectedFn()

Redis two-client architecture

Server A                      Redis                     Server B
+-----------+              +----------+               +-----------+
| WS Server |--redisPub-->|          |<--redisPub----| WS Server |
|           |<--redisSub--|  Pub/Sub |---redisSub--->|           |
+-----------+              +----------+               +-----------+
  • Duplication. WebSocketServerHelper duplicates its redisConnection twice (redisPub, redisSub); WebSocketEmitter duplicates it once (redisPub only).
  • Connection types. Both single-instance Redis and Cluster connections from ioredis are supported - the parent helper connection stays independent and unconsumed.
  • Dedup on receipt. Every server instance generates a unique serverId (UUID) at construction; messages carrying the same serverId on receipt are skipped to prevent double delivery to the originating instance.

Server API

Source -> server/helper.ts

Constructor

typescript
constructor(opts: IWebSocketServerOptions<AuthDataType, MetadataType>)

Generates a unique serverId (UUID), stores all options with defaults applied, and initializes two duplicated Redis clients (redisPub, redisSub). Throws getError({ statusCode: 500, message: '[WebSocketServerHelper] Invalid redis connection!' }) if redisConnection is falsy.

Generic type parameters

AuthDataType types the payload passed to authenticateFn/handshakeFn; MetadataType types the value returned as metadata and stored on IWebSocketClient. Both default to Record<string, unknown>.

typescript
interface AuthPayload { type: string; token: string; publicKey?: string }
interface UserMetadata { role: string; permissions: string[] }

const helper = new WebSocketServerHelper<AuthPayload, UserMetadata>({
  identifier: 'typed-ws',
  server: bunServer,
  redisConnection: redis,
  authenticateFn: async data => {
    // data is typed as AuthPayload
    const user = await verifyJWT(data.token);
    return user ? { userId: user.id, metadata: { role: user.role, permissions: user.permissions } } : null;
  },
  clientConnectedFn: ({ metadata }) => {
    // metadata is typed as UserMetadata | undefined
    if (metadata?.role === 'admin') console.log('admin connected');
  },
});

IWebSocketServerOptions

OptionTypeRequiredDefaultDescription
identifierstringYes-Unique name for this instance; also used as the BaseHelper logging scope
pathstringNo'/ws'URL path for WebSocket upgrade requests
serverIBunServerYes-Bun server instance (provides publish() for native pub/sub)
redisConnectionIRedisHelperYes-Redis helper for cross-instance messaging; duplicated twice internally
defaultRoomsstring[]No['ws-default', 'ws-notification']Rooms every client auto-joins after authentication
serverOptionsIBunWebSocketConfigNoSee Bun native configurationBun native WebSocket configuration
authTimeoutnumberNo5000 (5s)Milliseconds before an unauthenticated client is disconnected (close code 4001); extended to authTimeout * 3 while authenticateFn is in flight
heartbeatIntervalnumberNo30000 (30s)Milliseconds between heartbeat sweeps
heartbeatTimeoutnumberNo90000 (90s)Milliseconds of inactivity before an authenticated client is closed (close code 4002)
encryptedBatchLimitnumberNo10Max concurrent outboundTransformer invocations during room/broadcast delivery
requireEncryptionbooleanNofalseWhen true, clients must complete the handshake during auth or get disconnected (code 4004)
authenticateFnTWebSocketAuthenticateFnYes-Called on { event: 'authenticate' }. Return { userId, metadata } to accept, null/false (or throw) to reject
validateRoomFnTWebSocketValidateRoomFnNo-Called on { event: 'join' }. Return the allowed subset of requested rooms. All joins are rejected when this is not provided
clientConnectedFnTWebSocketClientConnectedFnNo-Called after successful authentication
clientDisconnectedFnTWebSocketClientDisconnectedFnNo-Called during disconnect cleanup
messageHandlerTWebSocketMessageHandlerNo-Called for events other than authenticate, heartbeat, join, leave from authenticated clients
outboundTransformerTWebSocketOutboundTransformerNo-Intercepts outbound { event, data } before socket.send(); enables per-client encryption
handshakeFnTWebSocketHandshakeFnNo-Required when requireEncryption is true. Returns { serverPublicKey, salt } to accept, null/false to reject
  • All callbacks run through invokeHook(). Applies to authenticateFn, validateRoomFn, clientConnectedFn, clientDisconnectedFn, messageHandler, and handshakeFn.
  • Failures do not crash the process. A synchronous throw inside a Bun socket handler is caught and logged; rejected promises are logged the same way via voidExecution.

configure()

typescript
configure(): Promise<void>

Must be called after construction and before accepting connections.

  1. Registers error listeners on redisPub and redisSub.
  2. Connects both duplicated clients if their status is 'wait' (lazyConnect mode).
  3. await Promise.all([waitForRedisReady(redisPub), waitForRedisReady(redisSub)]).
  4. Subscribes: subscribe('ws:broadcast'), psubscribe('ws:room:*'), psubscribe('ws:client:*'), psubscribe('ws:user:*') - awaited together before proceeding.
  5. Starts the heartbeat timer: setInterval(heartbeatAll, heartbeatInterval).

getBunWebSocketHandler()

typescript
getBunWebSocketHandler(): IBunWebSocketHandler

Returns the Bun WebSocket handler object. Pass it to server.reload({ websocket }).

CallbackWhenBehavior
openConnection establishedReads clientId from socket.data, calls onClientConnect()
messageMessage receivedUpdates client.lastActivity, calls onClientMessage() for routing
closeConnection closedCalls onClientDisconnect() for cleanup
drainBackpressure clearedSets client.backpressured = false

Bun native configuration

  • serverOptions is spread into the returned handler. WebSocketServerHelper only applies its own default for sendPings, idleTimeout, and maxPayloadLength.
  • Everything else falls through to Bun. The remaining fields are undefined unless you set them, and Bun applies its own runtime default in that case.
OptionTypeHelper defaultDescription
sendPingsbooleantrueBun transport-level pings
idleTimeoutnumber60 (seconds)Bun-level idle timeout (transport layer, separate from heartbeatTimeout)
maxPayloadLengthnumber131072 (128KB)Maximum incoming message size in bytes
perMessageDeflatebooleannot set by the helperEnable per-message compression
backpressureLimitnumbernot set by the helperBackpressure threshold in bytes
closeOnBackpressureLimitbooleannot set by the helperClose socket when the backpressure limit is exceeded
publishToSelfbooleannot set by the helperWhether server.publish() delivers to the publishing socket

getPath()

typescript
getPath(): string

Returns the configured WebSocket path (default: '/ws').

getClients()

typescript
getClients(opts?: { id?: string }):
  | IWebSocketClient<MetadataType>
  | Map<string, IWebSocketClient<MetadataType>>
  | undefined

Without id (or an empty opts), returns the full Map<string, IWebSocketClient>. With { id }, returns that client entry or undefined.

getClientsByUser()

typescript
getClientsByUser(opts: { userId: string }): IWebSocketClient<MetadataType>[]

Returns every client belonging to userId; [] if the user has no active connections.

getClientsByRoom()

typescript
getClientsByRoom(opts: { room: string }): IWebSocketClient<MetadataType>[]

Returns every client in room; [] if the room does not exist or is empty.

onClientConnect()

typescript
onClientConnect(opts: { clientId: string; socket: IWebSocket }): void

Handles a new WebSocket connection: creates an IWebSocketClient entry with state UNAUTHORIZED, subscribes the socket to its own clientId topic (Bun native pub/sub), and starts the authentication timeout. Returns early - and logs 'Client already existed' - if the client ID already exists.

onClientMessage()

typescript
onClientMessage(opts: { clientId: string; raw: string }): void

Routes an incoming message:

  • Parses raw as JSON. On failure, sends { event: 'error', data: { message: 'Invalid message format' } } and returns. A message with no event field is logged and dropped.
  • heartbeat: consumed silently (lastActivity is already updated by the message callback before this runs).
  • authenticate: delegates to handleAuthenticate().
  • Any other event, when the client is not AUTHENTICATED: sends { event: 'error', data: { message: 'Not authenticated' } }.
  • join / leave: delegate to the internal room handlers.
  • Everything else: delegates to messageHandler via invokeHook(), if configured; otherwise logged at debug and dropped.

onClientDisconnect()

typescript
onClientDisconnect(opts: { clientId: string }): void

Cleans up a disconnected client: clears the pending auth timer, removes the client from its user's index, removes it from every joined room, removes it from the clients map, then invokes clientDisconnectedFn.

joinRoom()

typescript
joinRoom(opts: { clientId: string; room: string }): void

Adds clientId to the room index and to client.rooms, and subscribes the socket to the room's Bun native pub/sub topic - unless the client is encrypted, in which case the subscribe step is skipped (delivery goes through the transformer instead). No-op if the client does not exist.

leaveRoom()

typescript
leaveRoom(opts: { clientId: string; room: string }): void

Removes clientId from the room index and from client.rooms, and unsubscribes the socket from the room's topic. No-op if the client does not exist.

Room name validation (client-initiated join/leave only)

handleJoin() filters requested room names before calling validateRoomFn:

  • Must be a non-empty string.

  • Maximum 256 characters.

  • Cannot start with ws: (reserved for the internal Redis channel prefix).

  • Rejection is silent. If every requested room is filtered out, validateRoomFn is not configured, or it resolves to an empty array, the join is rejected and logged - no error is sent back to the client.

  • leave does not re-filter. { event: 'leave' } only processes rooms the client is actually a member of (client.rooms.has(room)).

  • Programmatic calls bypass validation. joinRoom()/leaveRoom() called directly skip all of the above.

enableClientEncryption()

typescript
enableClientEncryption(opts: { clientId: string }): void

Unsubscribes the client from every Bun native pub/sub topic (the broadcast topic plus all joined rooms) so server.publish() no longer reaches it; messages are instead delivered individually through outboundTransformer. No-op if the client is already encrypted or does not exist.

WARNING

Irreversible for the lifetime of the connection. Once a client is encrypted it cannot be switched back to Bun native pub/sub delivery.

sendToClient()

typescript
sendToClient(opts: { clientId: string; event: string; data: unknown; doLog?: boolean }): void
  • Local delivery only.
  • Encrypted client, outboundTransformer configured. The transformer runs (async) before socket.send(); a transformer error is logged and the message is dropped.
  • Otherwise. Sends JSON.stringify({ event, data }) directly.
  • doLog: true emits an info log after delivery.

sendToUser()

typescript
sendToUser(opts: { userId: string; event: string; data: unknown }): void

Local delivery only. Iterates the user's client set and calls sendToClient() for each.

sendToRoom()

typescript
sendToRoom(opts: { room: string; event: string; data: unknown; exclude?: string[] }): void

Local delivery only.

ConditionStrategy
No outboundTransformer, no excludeBun native server.publish(room, payload) - O(1) C++ fan-out
outboundTransformer set, no excludeIterates room clients via executePromiseWithLimit (max encryptedBatchLimit concurrent)
exclude providedAlways iterates clients individually - Bun pub/sub cannot exclude

broadcast()

typescript
broadcast(opts: { event: string; data: unknown; exclude?: string[] }): void

Local delivery only, to AUTHENTICATED clients. Same delivery strategy as sendToRoom(), publishing to WebSocketDefaults.BROADCAST_TOPIC ('ws:internal:broadcast') instead of a room topic when no transformer/exclude applies.

send()

typescript
send<T = unknown>(opts: {
  destination?: string;
  payload: { topic: string; data: T };
  doLog?: boolean;
  callback?: () => void;
}): void

Cross-instance messaging: delivers locally and publishes to Redis so other server instances receive it.

destinationLocal deliveryRedis channel
Omittedbroadcast()ws:broadcast
Matches a locally-tracked client IDsendToClient()ws:client:{clientId}
Matches a locally-tracked room namesendToRoom()ws:room:{room}
Matches neither (remote target)Nonews:room:{destination}

Silent no-op when payload is falsy, payload.topic is falsy, or payload.data is undefined. When callback is provided it runs via setTimeout(callback, 0) regardless of delivery outcome.

shutdown()

typescript
shutdown(): Promise<void>
  1. Clears the heartbeat timer.
  2. Closes every client socket with code 1001 ('Server shutting down').
  3. Calls onClientDisconnect() for every tracked client (clears auth timers, removes from indexes, invokes clientDisconnectedFn).
  4. Clears the clients, users, and rooms maps.
  5. await Promise.all([redisPub.quit(), redisSub.quit()]).

Emitter API

Source -> emitter/helper.ts

  • Role. A Redis-only publisher for processes with no WebSocket server of their own - background workers, other microservices, cron jobs.
  • No self-skip. It always publishes with serverId: 'emitter', so every server instance processes the message; there is no dedup skip on the sending side.

Constructor

typescript
constructor(opts: IWebSocketEmitterOptions)

Duplicates one Redis client from redisConnection. Throws getError({ statusCode: 500, message: '[WebSocketEmitter] Invalid redis connection!' }) if redisConnection is falsy.

IWebSocketEmitterOptions

FieldTypeRequiredDefaultDescription
identifierstringNo'WebSocketEmitter'Instance name; also the BaseHelper logging scope
redisConnectionIRedisHelperYes-Redis helper; duplicated once internally

configure()

typescript
configure(): Promise<void>

Connects the Redis client (if status is 'wait') and awaits it reaching 'ready'. Must be called before emitting.

toClient()

typescript
toClient(opts: { clientId: string; event: string; data: unknown }): Promise<void>

Publishes to ws:client:{clientId}. Every server instance subscribed via psubscribe('ws:client:*') delivers it to that client if connected locally.

toUser()

typescript
toUser(opts: { userId: string; event: string; data: unknown }): Promise<void>

Publishes to ws:user:{userId}. Every server instance delivers it to every session belonging to that user.

toRoom()

typescript
toRoom(opts: { room: string; event: string; data: unknown; exclude?: string[] }): Promise<void>

Publishes to ws:room:{room}. Every server instance delivers it to every client in that room.

broadcast()

typescript
broadcast(opts: { event: string; data: unknown }): Promise<void>

Publishes to ws:broadcast. Every server instance delivers it to every authenticated client.

shutdown()

typescript
shutdown(): Promise<void>

Quits the duplicated Redis connection.

Types Reference

Source -> common/types.ts

Wire protocol

typescript
/** Client <-> Server message envelope */
interface IWebSocketMessage<DataType = unknown> {
  event: string;
  data?: DataType;
  id?: string;
}

/** Internal Redis Pub/Sub message envelope */
interface IRedisSocketMessage<DataType = unknown> {
  serverId: string;
  type: TWebSocketMessageType; // 'client' | 'user' | 'room' | 'broadcast'
  target?: string;
  event: string;
  data: DataType;
  exclude?: string[];
}

Client tracking

typescript
interface IWebSocketClient<
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> {
  id: string;
  userId?: string;
  socket: IWebSocket;
  state: TWebSocketClientState;
  rooms: Set<string>;
  backpressured: boolean;
  encrypted: boolean;
  connectedAt: number;
  lastActivity: number;
  metadata?: MetadataType;
  serverPublicKey?: string;
  salt?: string;
  authTimer?: ReturnType<typeof setTimeout>;
}

/** Data attached during server.upgrade() */
interface IWebSocketData<
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> {
  clientId: string;
  userId?: string;
  metadata?: MetadataType;
}

Bun interfaces

typescript
/** Bun WebSocket handle - defined locally to avoid an @types/bun dependency */
interface IWebSocket<T = unknown> {
  readonly data: T;
  readonly remoteAddress: string;
  readonly readyState: number;

  send(data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, compress?: boolean): number;
  subscribe(topic: string): void;
  unsubscribe(topic: string): void;
  isSubscribed(topic: string): boolean;
  close(code?: number, reason?: string): void;
  cork(callback: (ws: IWebSocket<T>) => void): void;
}

/** Bun server interface for native pub/sub */
interface IBunServer {
  readonly pendingWebSockets: number;
  publish(
    topic: string,
    data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer,
    compress?: boolean,
  ): number;
}

/** Bun native WebSocket configuration */
interface IBunWebSocketConfig {
  perMessageDeflate?: boolean;
  maxPayloadLength?: number;
  idleTimeout?: number;
  backpressureLimit?: number;
  closeOnBackpressureLimit?: boolean;
  sendPings?: boolean;
  publishToSelf?: boolean;
}

/** Return type of getBunWebSocketHandler() */
interface IBunWebSocketHandler extends IBunWebSocketConfig {
  open: (socket: IWebSocket) => void;
  message: (socket: IWebSocket, message: string | Buffer) => void;
  close: (socket: IWebSocket, code: number, reason: string) => void;
  drain: (socket: IWebSocket) => void;
}

Server and emitter options

typescript
interface IWebSocketServerOptions<
  AuthDataType extends Record<string, unknown> = Record<string, unknown>,
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> {
  identifier: string;
  path?: string; // Default: '/ws'
  redisConnection: IRedisHelper;
  server: IBunServer;
  defaultRooms?: string[]; // Default: ['ws-default', 'ws-notification']
  serverOptions?: IBunWebSocketConfig;
  authTimeout?: number; // Default: 5_000
  heartbeatInterval?: number; // Default: 30_000
  heartbeatTimeout?: number; // Default: 90_000
  encryptedBatchLimit?: number; // Default: 10
  requireEncryption?: boolean; // Default: false

  authenticateFn: TWebSocketAuthenticateFn<AuthDataType, MetadataType>;
  validateRoomFn?: TWebSocketValidateRoomFn;
  clientConnectedFn?: TWebSocketClientConnectedFn<MetadataType>;
  clientDisconnectedFn?: TWebSocketClientDisconnectedFn;
  messageHandler?: TWebSocketMessageHandler;
  outboundTransformer?: TWebSocketOutboundTransformer<unknown, MetadataType>;
  handshakeFn?: TWebSocketHandshakeFn<AuthDataType>; // Required when requireEncryption is true
}

interface IWebSocketEmitterOptions {
  identifier?: string; // Default: 'WebSocketEmitter'
  redisConnection: IRedisHelper;
}

Callback types

typescript
/** Authentication - return { userId, metadata } to accept, null/false to reject */
type TWebSocketAuthenticateFn<
  AuthDataType extends Record<string, unknown> = Record<string, unknown>,
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> = (
  opts: AuthDataType,
) => ValueOrPromise<{ userId?: string; metadata?: MetadataType } | null | false>;

/** Handshake during auth - return { serverPublicKey, salt } to accept, null/false to reject */
type TWebSocketHandshakeFn<
  AuthDataType extends Record<string, unknown> = Record<string, unknown>,
> = (opts: {
  clientId: string;
  userId?: string;
  data: AuthDataType;
}) => ValueOrPromise<{ serverPublicKey: string; salt: string } | null | false>;

/** Room validation - return the allowed subset of requested rooms */
type TWebSocketValidateRoomFn = (opts: {
  clientId: string;
  userId?: string;
  rooms: string[];
}) => ValueOrPromise<string[]>;

/** Post-authentication callback */
type TWebSocketClientConnectedFn<
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> = (opts: { clientId: string; userId?: string; metadata?: MetadataType }) => ValueOrPromise<void>;

/** Disconnect callback */
type TWebSocketClientDisconnectedFn = (opts: {
  clientId: string;
  userId?: string;
}) => ValueOrPromise<void>;

/** Custom event handler for events other than authenticate/heartbeat/join/leave */
type TWebSocketMessageHandler = (opts: {
  clientId: string;
  userId?: string;
  message: IWebSocketMessage;
}) => ValueOrPromise<void>;

/** Outbound transformer - intercepts messages before socket.send() */
type TWebSocketOutboundTransformer<
  DataType = unknown,
  MetadataType extends Record<string, unknown> = Record<string, unknown>,
> = (opts: {
  client: IWebSocketClient<MetadataType>;
  event: string;
  data: DataType;
}) => ValueOrPromise<TNullable<{ event: string; data: DataType }>>;

State types

typescript
type TWebSocketClientState = 'unauthorized' | 'authenticating' | 'authenticated' | 'disconnected';
type TWebSocketEvent = 'authenticate' | 'connected' | 'disconnect' | 'join' | 'leave' | 'error' | 'heartbeat' | 'encrypted';
type TWebSocketMessageType = 'client' | 'user' | 'room' | 'broadcast';

Constants

Source -> common/constants.ts

WebSocketEvents

ConstantValueDescription
AUTHENTICATE'authenticate'Client -> Server auth request
CONNECTED'connected'Server -> Client auth success
DISCONNECT'disconnect'Disconnection event
JOIN'join'Room join request
LEAVE'leave'Room leave request
ERROR'error'Server -> Client error message
HEARTBEAT'heartbeat'Client -> Server keep-alive
ENCRYPTED'encrypted'Encrypted message wrapper
typescript
WebSocketEvents.isValid('authenticate'); // true
WebSocketEvents.isValid('invalid');      // false
WebSocketEvents.SCHEME_SET;              // Set of all valid event strings

WebSocketChannels

Constant / MethodValueDescription
BROADCAST'ws:broadcast'Broadcast channel
ROOM_PREFIX'ws:room:'Room channel prefix
CLIENT_PREFIX'ws:client:'Client channel prefix
USER_PREFIX'ws:user:'User channel prefix
forRoom({ room })'ws:room:{room}'Build a room channel name
forClient({ clientId })'ws:client:{clientId}'Build a client channel name
forUser({ userId })'ws:user:{userId}'Build a user channel name
forRoomPattern()'ws:room:*'Room pattern for psubscribe
forClientPattern()'ws:client:*'Client pattern for psubscribe
forUserPattern()'ws:user:*'User pattern for psubscribe

WebSocketDefaults

ConstantValueDescription
PATH'/ws'Default WebSocket path
ROOM'ws-default'Default room
NOTIFICATION_ROOM'ws-notification'Default notification room
BROADCAST_TOPIC'ws:internal:broadcast'Bun pub/sub broadcast topic
MAX_PAYLOAD_LENGTH131072 (128KB)Maximum incoming payload size
IDLE_TIMEOUT60 (seconds)Bun transport idle timeout
BACKPRESSURE_LIMIT1048576 (1MB)Bun backpressure threshold (Bun's own default, not applied by the helper)
SEND_PINGStrueBun transport pings enabled
PUBLISH_TO_SELFfalseBun pub/sub self-delivery disabled (Bun's own default, not applied by the helper)
AUTH_TIMEOUT5000 (5s)Authentication timeout
HEARTBEAT_INTERVAL30000 (30s)Heartbeat sweep interval
HEARTBEAT_TIMEOUT90000 (90s, 3x interval)Heartbeat inactivity threshold
ENCRYPTED_BATCH_LIMIT10Max concurrent outboundTransformer invocations

WebSocketMessageTypes

ConstantValueDescription
CLIENT'client'Message targeted at a specific client
USER'user'Message targeted at all sessions of a user
ROOM'room'Message targeted at a room
BROADCAST'broadcast'Message targeted at all clients
typescript
WebSocketMessageTypes.isValid('room'); // true
WebSocketMessageTypes.SCHEME_SET;      // Set { 'client', 'user', 'room', 'broadcast' }

WebSocketClientStates

ConstantValueDescription
UNAUTHORIZED'unauthorized'Initial state after connection
AUTHENTICATING'authenticating'authenticate event received, awaiting authenticateFn
AUTHENTICATED'authenticated'Successfully authenticated, fully operational
DISCONNECTED'disconnected'Client has disconnected
typescript
WebSocketClientStates.isValid('authenticated'); // true
WebSocketClientStates.SCHEME_SET;               // Set of all valid state strings

Close codes

CodeMeaningTrigger
4001Authentication timeoutClient did not authenticate within authTimeout (or authTimeout * 3 once authenticateFn started)
4002Heartbeat timeoutNo activity for heartbeatTimeout
4003Authentication failedauthenticateFn returned null/false or threw
4004Encryption requiredrequireEncryption is true and handshakeFn is missing or rejected
1001Going awayServer shutting down gracefully (shutdown())

Troubleshooting

Client disconnects immediately with close code 4001

  • Symptom. The client connects but is closed before it can interact - it did not send { event: 'authenticate', data: { ... } } within authTimeout (default 5s).
  • Wrong message shape. e.g. sending { type: 'auth' } instead of { event: 'authenticate' }.
  • Client waits for the server first. The server sends nothing after upgrade - the client must initiate.
  • Slow token retrieval. Pushes the auth message past the timeout window.

TIP

Send { event: 'authenticate', data: { token: '...' } } immediately in the client's onopen handler. If token retrieval is slow, raise authTimeout.

helper.send() delivers locally but other instances never receive it

  • Topology mismatch. A single-instance Redis client against a Redis Cluster deployment will not route correctly.
  • Configure ordering. await helper.configure() must run to completion before you start accepting connections - subscriptions are set up asynchronously.
  • Network/ACL. No firewall or ACL should block SUBSCRIBE/PSUBSCRIBE on the duplicated clients.

requireEncryption is true but clients get disconnected with code 4004

  • handshakeFn missing. The server logs "requireEncryption is true but no handshakeFn configured" and closes the client.
  • handshakeFn rejected. It returned null/false - typically because required key-exchange data (e.g. publicKey) was missing from the authenticate payload.

[WebSocketServerHelper] Invalid redis connection! / [WebSocketEmitter] Invalid redis connection!

Thrown synchronously during construction when redisConnection is null/undefined. Pass a valid IRedisHelper instance (e.g. RedisSingleHelper).

Redis client did not become ready within 30000ms

Thrown during configure() when a duplicated Redis client fails to reach 'ready' status. Check that the Redis server is reachable and the underlying IRedisHelper instance is configured correctly.

See also