Skip to content

WebSocket Component Reference

Every binding key, configuration option, callback signature, and internal mechanism of WebSocketComponent. For the task-oriented walkthrough, see Usage & Examples.

Files:

Quick reference

ItemValue
Package@venizia/ignis (core component) + @venizia/ignis-helpers (helper classes)
Component classWebSocketComponent
Server helperWebSocketServerHelper
Emitter helperWebSocketEmitter (standalone Redis publisher)
RuntimesBun only - throws on Node.js
ScalingRedis Pub/Sub (ioredis - single or Cluster)

Import paths

WebSocketComponent and WebSocketBindingKeys are exported only from the @venizia/ignis/websocket subpath - never from the @venizia/ignis root barrel. IServerOptions (the core component's options subset) is not exported from either entry point.

typescript
// Core - subpath import only
import { WebSocketComponent, WebSocketBindingKeys } from '@venizia/ignis/websocket';

// Helpers - types, classes, constants from the main entry
import {
  WebSocketServerHelper,
  WebSocketEmitter,
  WebSocketDefaults,
  WebSocketEvents,
  WebSocketChannels,
  WebSocketClientStates,
  WebSocketMessageTypes,
} from '@venizia/ignis-helpers';

import type {
  IWebSocketServerOptions,
  IWebSocketEmitterOptions,
  IWebSocketClient,
  IWebSocketMessage,
  IRedisSocketMessage,
  IBunWebSocketConfig,
  TWebSocketAuthenticateFn,
  TWebSocketValidateRoomFn,
  TWebSocketClientConnectedFn,
  TWebSocketClientDisconnectedFn,
  TWebSocketMessageHandler,
  TWebSocketOutboundTransformer,
  TWebSocketHandshakeFn,
} from '@venizia/ignis-helpers';

Configuration

WebSocketComponent's own IServerOptions interface is a subset of the helper's IWebSocketServerOptions - the component fills in server, redisConnection, callback functions, authTimeout, and encryptedBatchLimit from the DI container before constructing the helper.

typescript
interface IServerOptions {
  identifier: string;                  // Default: 'WEBSOCKET_SERVER'
  path?: string;                       // Default: '/ws'
  defaultRooms?: string[];             // Default: ['ws-default', 'ws-notification']
  serverOptions?: IBunWebSocketConfig; // Bun native WebSocket config
  heartbeatInterval?: number;          // Default: 30000 (30s)
  heartbeatTimeout?: number;           // Default: 90000 (90s)
  requireEncryption?: boolean;         // Default: false
}

NOTE

DEFAULT_SERVER_OPTIONS in the core component only sets identifier and path. defaultRooms, heartbeatInterval, heartbeatTimeout, and serverOptions fall back to WebSocketDefaults inside the helper constructor, not the component.

NOTE

authTimeout and encryptedBatchLimit belong to the helper's IWebSocketServerOptions, not the component's IServerOptions. There is no binding key for them - the component always passes the helper defaults (5000 ms, 10). Customize them only by constructing WebSocketServerHelper yourself outside the component.

Bind a partial object to SERVER_OPTIONS before registering the component to override any field:

typescript
this.bind({ key: WebSocketBindingKeys.SERVER_OPTIONS }).toValue({
  identifier: 'my-app-websocket',
  path: '/realtime',
  defaultRooms: ['general', 'announcements'],
  heartbeatInterval: 20000,
  heartbeatTimeout: 60000,
  requireEncryption: true,
  serverOptions: { maxPayloadLength: 2097152, backpressureLimit: 2097152 },
});

WebSocketDefaults constants

ConstantValueDescription
PATH'/ws'Default WebSocket endpoint path
ROOM'ws-default'Default room name
NOTIFICATION_ROOM'ws-notification'Default notification room name
BROADCAST_TOPIC'ws:internal:broadcast'Internal Bun pub/sub broadcast topic
MAX_PAYLOAD_LENGTH131072 (128 KB)Maximum message payload size
IDLE_TIMEOUT60Bun idle timeout, seconds
BACKPRESSURE_LIMIT1048576 (1 MB)Bun backpressure limit
SEND_PINGStrueEnable WebSocket pings
PUBLISH_TO_SELFfalseWhether the server receives its own publishes
AUTH_TIMEOUT5000 (5 s)Time to authenticate before disconnect
HEARTBEAT_INTERVAL30000 (30 s)Interval between heartbeat sweeps
HEARTBEAT_TIMEOUT90000 (90 s)Disconnect after 3 missed heartbeats
ENCRYPTED_BATCH_LIMIT10Max concurrent encryption operations

MAX_PAYLOAD_LENGTH, IDLE_TIMEOUT, BACKPRESSURE_LIMIT, SEND_PINGS, and PUBLISH_TO_SELF are Bun-native settings passed via serverOptions. The rest are application-level settings read directly off IWebSocketServerOptions.

IBunWebSocketConfig

typescript
interface IBunWebSocketConfig {
  perMessageDeflate?: boolean;
  maxPayloadLength?: number;          // Default: 128 KB (131072)
  idleTimeout?: number;               // Default: 60s
  backpressureLimit?: number;         // Default: 1 MB (1048576)
  closeOnBackpressureLimit?: boolean;
  sendPings?: boolean;                // Default: true
  publishToSelf?: boolean;            // Default: false
}

Passed straight through to Bun's native WebSocket handler via serverOptions inside SERVER_OPTIONS.

Binding keys

Binding KeyConstantTypeRequiredDefault
@app/websocket/server-optionsWebSocketBindingKeys.SERVER_OPTIONSPartial<IServerOptions>NoSee Configuration
@app/websocket/redis-connectionWebSocketBindingKeys.REDIS_CONNECTIONAbstractRedisHelperYesnull
@app/websocket/authenticate-handlerWebSocketBindingKeys.AUTHENTICATE_HANDLERTWebSocketAuthenticateFnYesnull
@app/websocket/validate-room-handlerWebSocketBindingKeys.VALIDATE_ROOM_HANDLERTWebSocketValidateRoomFnNonull
@app/websocket/client-connected-handlerWebSocketBindingKeys.CLIENT_CONNECTED_HANDLERTWebSocketClientConnectedFnNonull
@app/websocket/client-disconnected-handlerWebSocketBindingKeys.CLIENT_DISCONNECTED_HANDLERTWebSocketClientDisconnectedFnNonull
@app/websocket/message-handlerWebSocketBindingKeys.MESSAGE_HANDLERTWebSocketMessageHandlerNonull
@app/websocket/outbound-transformerWebSocketBindingKeys.OUTBOUND_TRANSFORMERTWebSocketOutboundTransformerNonull
@app/websocket/handshake-handlerWebSocketBindingKeys.HANDSHAKE_HANDLERTWebSocketHandshakeFnNo*null
@app/websocket/instanceWebSocketBindingKeys.WEBSOCKET_INSTANCEWebSocketServerHelper--Set by the component
  • HANDSHAKE_HANDLER becomes required when IServerOptions.requireEncryption is true - it performs the ECDH key exchange during authentication.
  • WEBSOCKET_INSTANCE is never bound by application code - the component binds it automatically inside the post-start hook, after the server starts. Inject it lazily; see Usage & Examples.

Callback signatures

Binding KeyCallback TypeRequiredDescription
AUTHENTICATE_HANDLERTWebSocketAuthenticateFnYesReturns { userId, metadata } or null/false to reject
VALIDATE_ROOM_HANDLERTWebSocketValidateRoomFnNoFilters requested rooms, returns allowed rooms
CLIENT_CONNECTED_HANDLERTWebSocketClientConnectedFnNoCalled after successful authentication
CLIENT_DISCONNECTED_HANDLERTWebSocketClientDisconnectedFnNoCalled on disconnect, after cleanup
MESSAGE_HANDLERTWebSocketMessageHandlerNoHandles non-system messages from authenticated clients
OUTBOUND_TRANSFORMERTWebSocketOutboundTransformerNoTransforms outbound messages (e.g. per-client encryption)
HANDSHAKE_HANDLERTWebSocketHandshakeFnWhen requireEncryption: trueReturns { serverPublicKey, salt } or null/false to reject
typescript
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>;

type TWebSocketValidateRoomFn = (opts: {
  clientId: string;
  userId?: string;
  rooms: string[];
}) => ValueOrPromise<string[]>;

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

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

type TWebSocketMessageHandler = (opts: {
  clientId: string;
  userId?: string;
  message: IWebSocketMessage;
}) => ValueOrPromise<void>;

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 }>>;

type TWebSocketHandshakeFn<AuthDataType extends Record<string, unknown> = Record<string, unknown>> = (
  opts: { clientId: string; userId?: string; data: AuthDataType },
) => ValueOrPromise<{ serverPublicKey: string; salt: string } | null | false>;
  • VALIDATE_ROOM_HANDLER receives sanitized rooms. Internal ws:-prefixed rooms are already filtered out before this callback runs. Without it bound, all join requests are rejected.
  • CLIENT_CONNECTED_HANDLER / CLIENT_DISCONNECTED_HANDLER errors are caught and logged, never thrown - a broken hook cannot disconnect a client or crash the server.
  • MESSAGE_HANDLER only sees non-system events (authenticate, connected, disconnect, join, leave, error, heartbeat, encrypted are all handled internally). Unbound, non-system messages are silently dropped.
  • OUTBOUND_TRANSFORMER only runs for encrypted clients (client.encrypted === true). Non-encrypted clients bypass it entirely - zero overhead until encryption is enabled.

Architecture

Lifecycle integration

The component uses the application's post-start hook system to solve a timing problem: WebSocket needs a running Bun server instance, but components initialize before the server starts.

preConfigure()          <- register WebSocketComponent here
      |
initialize()             <- component.binding() runs: runtime check, resolve bindings, register post-start hook
      |
setupMiddlewares()
      |
startBunModule()          <- Bun server starts, instance created
      |
executePostStartHooks()   <- websocket-initialize hook runs:
      |                       new WebSocketServerHelper(...)
      |                       await wsHelper.configure()
      |                       bind WEBSOCKET_INSTANCE
      |                       server.reload({ fetch, websocket })

Fetch handler

createBunFetchHandler() builds the fetch function passed to server.reload(). It routes every incoming request:

Incoming Request
  Is a WebSocket upgrade? (pathname === wsPath && headers.upgrade === 'websocket')
    Yes -> server.upgrade(req, { data: { clientId: crypto.randomUUID() } })
             success -> return undefined (Bun handles the connection)
             failure -> return Response('WebSocket upgrade failed', { status: 500 })
    No  -> honoServer.fetch(req, server)   // note: second arg is the raw server, not wrapped
typescript
function createBunFetchHandler(opts: {
  wsPath: string;
  honoServer: OpenAPIHono;
}): (req: Request, server: TBunServerInstance) => Promise<Response | undefined>

WebSocketEmitter API

Standalone, lightweight Redis-only publisher for processes that do not run a WebSocketServerHelper. Extends BaseHelper; uses a single Redis pub client.

typescript
interface IWebSocketEmitterOptions {
  identifier?: string;            // Default: 'WebSocketEmitter' (logger scope)
  redisConnection: IRedisHelper;  // Required - same Redis as the server(s)
}
  • Constructor calls super({ scope }), throws "Invalid redis connection!" if redisConnection is falsy, and calls redisConnection.duplicateClient() to create an isolated pub client.
  • EMITTER_SERVER_ID = 'emitter'. Every message the emitter publishes carries this fixed serverId. No WebSocketServerHelper ever has this ID (they use crypto.randomUUID()), so no server self-dedups an emitter message.
MethodSignatureBehavior
configure()(): Promise<void>Registers a Redis error handler, connects if lazy (status === 'wait'), waits for 'ready' (30s timeout). Call before any send method.
toClient()(opts: { clientId; event; data }): Promise<void>Publishes to ws:client:{clientId}
toUser()(opts: { userId; event; data }): Promise<void>Publishes to ws:user:{userId}
toRoom()(opts: { room; event; data; exclude? }): Promise<void>Publishes to ws:room:{room}, forwarding exclude
broadcast()(opts: { event; data }): Promise<void>Publishes to ws:broadcast
shutdown()(): Promise<void>Calls redisPub.quit() - always call when the emitter is no longer needed

Internals

resolveBindings()

Reads all binding keys and validates the required ones, throwing before the post-start hook is even registered:

BindingValidationError on failure
SERVER_OPTIONSOptional, merged with DEFAULT_SERVER_OPTIONS via Object.assign()--
REDIS_CONNECTIONMust be instanceof AbstractRedisHelper"Invalid instance of redisConnection ..."
AUTHENTICATE_HANDLERMust be truthy"Invalid authenticateFn to setup WebSocket server!"
VALIDATE_ROOM_HANDLER / CLIENT_CONNECTED_HANDLER / CLIENT_DISCONNECTED_HANDLER / MESSAGE_HANDLER / OUTBOUND_TRANSFORMER / HANDSHAKE_HANDLEROptional, null coerced to undefined--

registerBunHook()

Registers the websocket-initialize post-start hook:

  1. Gets the Bun server instance (getServerInstance()) and Hono server (getServer()) - throws "[WebSocketComponent] Bun server instance not available!" if the Bun instance is missing.
  2. Constructs WebSocketServerHelper with all resolved bindings plus the running server instance.
  3. Awaits wsHelper.configure() - connects Redis clients, sets up subscriptions, starts the heartbeat timer.
  4. Binds the helper to WEBSOCKET_INSTANCE.
  5. Calls serverInstance.reload({ fetch: createBunFetchHandler(...), websocket: wsHelper.getBunWebSocketHandler() }).

Runtime check

Runs at the top of binding(), before anything else:

typescript
const runtime = RuntimeModules.detect();
if (runtime === RuntimeModules.NODE) {
  throw getError({ message: '[WebSocketComponent] Node.js runtime is not supported yet. Please use Bun runtime.' });
}

Bun WebSocket handler

WebSocketServerHelper.getBunWebSocketHandler() returns an IBunWebSocketHandler - four lifecycle callbacks plus the config spread from serverOptions:

CallbackResponsibility
openCreates the IWebSocketClient entry in state UNAUTHORIZED, subscribes the socket to its own clientId topic, starts the auth timer (skips if clientId already exists)
messageUpdates lastActivity; parses JSON (sends error on failure); routes heartbeat (no-op), authenticate, other events on unauthenticated clients (error: "Not authenticated"), join, leave, or custom events to messageHandler
closeClears the auth timer, removes the client from users/rooms/clients, invokes clientDisconnectedFn (errors caught and logged)
drainResets client.backpressured = false

deliverToSocket() backpressure handling

socket.send() returnMeaningAction
> 0Sent successfully (byte count)None
0Dropped (socket already closed)Logs "Message dropped (socket closed)"
-1Backpressure (Bun's send buffer full)Sets client.backpressured = true, logs a warning. Bun still queues the message; drain fires and resets the flag once the buffer clears

Any exception thrown by socket.send() is caught and logged.

send() destination resolution

send({ destination, payload: { topic, data } })
  destination undefined/null?      -> broadcast locally + publishToRedis(BROADCAST)
  destination is a local clientId? -> sendToClient locally + publishToRedis(CLIENT)
  destination is a local room?     -> sendToRoom locally + publishToRedis(ROOM)
  destination unknown locally?     -> publishToRedis(ROOM, target: destination)  // may be a room on another instance

IMPORTANT

send() has no USER type. To reach every session of a user, use sendToUser() (local) or WebSocketEmitter.toUser() (cross-instance).

Room join / leave validation

  • Server-side sanitization always applies: room must be a non-empty string, at most 256 characters, and must not start with the reserved ws: prefix.
  • validateRoomFn gates joins. Only sanitized rooms reach it; it returns the subset the client may actually join. If unbound, every join is rejected with a warning log.
  • Leave is filtered against joined rooms. handleLeave() computes rooms.filter(r => client.rooms.has(r)) before leaving, so a client can never unsubscribe from a room it never joined (or an internal topic). If nothing remains after filtering, the leave is silently ignored.

Graceful shutdown

typescript
override async stop(): Promise<void> {
  const wsHelper = this.get<WebSocketServerHelper>({
    key: WebSocketBindingKeys.WEBSOCKET_INSTANCE,
    isOptional: true,
  });
  if (wsHelper) {
    await wsHelper.shutdown();
  }
  if (this.redisHelper) {
    await this.redisHelper.disconnect();
  }
  await super.stop();
}

wsHelper.shutdown():

  1. Clears the heartbeat timer.
  2. Closes every socket with close(1001, 'Server shutting down') (errors caught per-client - already-disconnected clients are logged, not thrown).
  3. Runs onClientDisconnect() for every client, so clientDisconnectedFn still fires for each.
  4. Clears the clients, users, and rooms maps.
  5. Quits both Redis clients (redisPub.quit() + redisSub.quit()) in parallel.

emitter.shutdown() is simpler - it only owns one Redis client and no local state: redisPub.quit().

See also