Skip to content

Socket.IO -- API Reference

Architecture deep dive, method signatures, internals, and type definitions.

Architecture

The component integrates Socket.IO into the Ignis application lifecycle with runtime-specific initialization (Node.js vs Bun).

Architecture Diagram

                         SocketIOComponent
                        +----------------------------------------------+
                        |                                              |
                        |  binding()                                   |
                        |    |-- resolveBindings()                     |
                        |    |     |-- SERVER_OPTIONS                  |
                        |    |     |-- REDIS_CONNECTION                |
                        |    |     |-- AUTHENTICATE_HANDLER            |
                        |    |     |-- VALIDATE_ROOM_HANDLER           |
                        |    |     +-- CLIENT_CONNECTED_HANDLER        |
                        |    |                                         |
                        |    +-- RuntimeModules.detect()               |
                        |          |-- BUN  -> registerBunHook()       |
                        |          +-- NODE -> registerNodeHook()      |
                        |                                              |
                        |  (Post-start hooks execute after server)     |
                        |    |-- Creates SocketIOServerHelper          |
                        |    |-- await socketIOHelper.configure()      |
                        |    |-- Binds to SOCKET_IO_INSTANCE           |
                        |    +-- Wires into server (runtime-specific)  |
                        +----------------------------------------------+

Lifecycle Integration

The component uses the post-start hook system to solve a fundamental timing problem: Socket.IO needs a running server instance, but components are initialized before the server starts.

Application Lifecycle Diagram

Application Lifecycle
=====================

  +------------------+
  |  preConfigure()  | <-- Register SocketIOComponent here
  +--------+---------+
           |
  +--------v---------+
  |  initialize()    | <-- Component.binding() runs here
  |                  |   Resolves bindings, registers post-start hook
  +--------+---------+
           |
  +--------v---------+
  | setupMiddlewares  |
  +--------+---------+
           |
  +--------v-----------------------+
  | startBunModule()  OR          | <-- Server starts, instance created
  | startNodeModule()             |
  +--------+-----------------------+
           |
  +--------v--------------------------+
  | executePostStartHooks()           | <-- SocketIOServerHelper created HERE
  |   +-- socket-io-initialize        |   Server instance is now available
  +-----------------------------------+

Runtime-Specific Behavior

AspectNode.jsBun
Server Typenode:http.ServerBun.Server
IO Server Initnew IOServer(httpServer, opts)new IOServer() + io.bind(engine)
EngineBuilt-in (socket.io)@socket.io/bun-engine (optional peer dep)
Request RoutingSocket.IO attaches to HTTP server automaticallyserver.reload({ fetch, websocket }) wires engine into Bun's request loop
WebSocket UpgradeHandled by node:http.Server upgrade eventHandled by Bun's websocket handler
Dynamic ImportNone neededawait import('@socket.io/bun-engine') at runtime
Fetch HandlerNot needed -- HTTP server handles upgradesCustom fetch wraps Hono fetch, routes WS upgrades to engine
CORSHandled by socket.io CORS optionsHandled by Bun engine options (requires explicit field bridging)
Server AccessDirect -- Socket.IO attaches to HTTP serverserver.reload({ fetch, websocket }) to hot-swap handlers

Runtime Differences -- Deep Dive

Bun Runtime

The Bun handler creates a custom fetch function that intercepts WebSocket upgrade requests:

  1. Checks if the request path matches the Socket.IO path (serverOptions.path, default '/io')
  2. If yes, delegates to @socket.io/bun-engine via engine.handleRequest(req, server) for WebSocket protocol handling
  3. If no, delegates to Hono's normal server.fetch(req, server) handler

Bun Fetch Handler Source

typescript
function createBunFetchHandler(opts: {
  engine: any;
  enginePath: string;
  honoServer: OpenAPIHono;
}): (req: Request, server: TBunServerInstance) => Response | Promise<Response> {
  const { engine, enginePath, honoServer } = opts;

  return (req: Request, server: TBunServerInstance): Response | Promise<Response> => {
    const url = new URL(req.url);

    if (!url.pathname.startsWith(enginePath)) {
      return honoServer.fetch(req, server);
    }

    return engine.handleRequest(req, server) ?? new Response(null, { status: 404 });
  };
}

CORS type bridging: Socket.IO and @socket.io/bun-engine have slightly different CORS type definitions. The component extracts individual CORS fields explicitly to avoid type mismatches without using as any:

Bun Engine CORS Bridging

typescript
const corsConfig = typeof serverOptions.cors === 'object' ? serverOptions.cors : undefined;
const engine = new BunEngine({
  path: serverOptions.path ?? '/socket.io/',
  ...(corsConfig && {
    cors: {
      origin: corsConfig.origin as string | RegExp | (string | RegExp)[] | undefined,
      methods: corsConfig.methods,
      credentials: corsConfig.credentials,
      allowedHeaders: corsConfig.allowedHeaders,
      exposedHeaders: corsConfig.exposedHeaders,
      maxAge: corsConfig.maxAge,
    },
  }),
});

Node.js Runtime

Node mode is simpler because Socket.IO natively attaches to node:http.Server. The handler creates a SocketIOServerHelper with runtime: RuntimeModules.NODE and passes the HTTP server instance directly:

Node.js Handler Source

typescript
async function createNodeSocketIOHelper(opts: {
  serverOptions: Partial<IServerOptions>;
  httpServer: TNodeServerInstance;
  resolvedBindings: IResolvedBindings;
}): Promise<SocketIOServerHelper> {
  const { serverOptions, httpServer, resolvedBindings } = opts;
  const { redisConnection, authenticateFn, validateRoomFn, clientConnectedFn } = resolvedBindings;

  const socketIOHelper = new SocketIOServerHelper({
    runtime: RuntimeModules.NODE,
    identifier: serverOptions.identifier!,
    server: httpServer,
    serverOptions,
    redisConnection,
    authenticateFn,
    validateRoomFn,
    clientConnectedFn,
  });
  await socketIOHelper.configure();

  return socketIOHelper;
}

Server Helper API Reference

SocketIOServerHelper Constructor

The helper uses a discriminated union for its constructor options, keyed on runtime:

TSocketIOServerOptions Type

typescript
interface ISocketIOServerBaseOptions {
  identifier: string;
  serverOptions: Partial<ServerOptions>;
  redisConnection: DefaultRedisHelper;
  defaultRooms?: string[];               // Default: ['io-default', 'io-notification']
  authenticateTimeout?: number;           // Default: 10_000 (10 seconds)
  pingInterval?: number;                  // Default: 30_000 (30 seconds)

  authenticateFn: TSocketIOAuthenticateFn;
  validateRoomFn?: TSocketIOValidateRoomFn;
  clientConnectedFn?: TSocketIOClientConnectedFn;
}

interface ISocketIOServerNodeOptions extends ISocketIOServerBaseOptions {
  runtime: typeof RuntimeModules.NODE;
  server: HTTPServer;                     // node:http.Server instance
}

interface ISocketIOServerBunOptions extends ISocketIOServerBaseOptions {
  runtime: typeof RuntimeModules.BUN;
  engine: any;                            // @socket.io/bun-engine Server instance
}

type TSocketIOServerOptions = ISocketIOServerNodeOptions | ISocketIOServerBunOptions;

During construction:

  1. Sets identifier, runtime, serverOptions, callback functions
  2. Sets defaults: authenticateTimeout = 10s, pingInterval = 30s, defaultRooms = ['io-default', 'io-notification']
  3. Calls setRuntime() -- validates and stores the server or engine
  4. Calls initRedisClients() -- creates 3 duplicated Redis clients from the connection

IMPORTANT

Redis clients are duplicated from the parent connection (client.duplicate()). This means the helper uses 3 independent connections (pub, sub, emitter) that inherit config from the parent but maintain separate state. The parent RedisHelper connection is not consumed.

configure() -- Server Initialization

The configure() method is the main initialization entry point, called after construction:

configure()
  |-- Register error handlers on all 3 Redis clients
  |-- Connect any clients in 'wait' status (lazyConnect mode)
  |-- await Promise.all([redisPub.ready, redisSub.ready, redisEmitter.ready])
  |-- initIOServer()
  |     |-- NODE: new IOServer(httpServer, serverOptions)
  |     +-- BUN:  new IOServer() -> io.bind(bunEngine)
  |-- io.adapter(createAdapter(redisPub, redisSub))
  |-- emitter = new Emitter(redisEmitter)
  +-- io.on('connection', onClientConnect)

NOTE

The configure() method is async because it waits for all 3 Redis connections to be ready before proceeding. If any Redis client fails to connect, the error propagates and the server will not start.

Public Methods

getIOServer()

typescript
getIOServer(): IOServer

Returns the underlying socket.io Server instance. Use this for direct access to Socket.IO APIs not exposed by the helper (e.g., io.of('/namespace'), io.fetchSockets()).

getEngine()

typescript
getEngine(): any

Returns the @socket.io/bun-engine instance. Throws if the runtime is Node.js ("Engine is only available for Bun runtime!").

getClients()

typescript
// Overloaded:
getClients(): Map<string, ISocketIOClient>
getClients(opts: { id: string }): ISocketIOClient | undefined

When called without arguments, returns the full client map. When called with { id }, returns the specific client entry or undefined if not found.

on()

typescript
on<HandlerArgsType extends unknown[] = unknown[], HandlerReturnType = void>(opts: {
  topic: string;
  handler: (...args: HandlerArgsType) => ValueOrPromise<HandlerReturnType>;
}): void

Registers a server-level event handler on the IO server. Throws if topic is empty, handler is falsy, or the IO server is not initialized.

ping()

typescript
ping(opts: { socket: IOSocket; doIgnoreAuth: boolean }): void

Sends a ping event to a specific client with { time: <ISO string> }. Behavior:

  • If socket is undefined, logs and returns
  • If client is not found in the client map, logs and returns
  • If doIgnoreAuth is false and the client is not authenticated, disconnects the client
  • If doIgnoreAuth is true, sends the ping regardless of auth state

Used internally for the keep-alive interval after authentication. The doIgnoreAuth: true flag is used for the initial post-auth ping and the recurring interval.

disconnect()

typescript
disconnect(opts: { socket: IOSocket }): void

Disconnects a specific client and cleans up resources:

  1. Clears the ping interval (if set)
  2. Clears the authentication timeout
  3. Removes the client from the clients map
  4. Calls socket.disconnect() on the underlying Socket.IO socket

If the socket is undefined or not tracked in the client map, the method still calls socket.disconnect() for safety.

onClientConnect()

typescript
onClientConnect(opts: { socket: IOSocket }): void

Handles a new socket connection. Called by the connection event handler on the IO server. This method is public so it can be invoked externally for testing or custom connection routing.

Behavior:

  1. Validates the socket exists (returns if null/undefined)
  2. Checks for duplicate connections by socket ID (returns if already tracked)
  3. Creates an ISocketIOClient entry with state UNAUTHORIZED
  4. Starts the authentication timeout (authenticateTimeout ms)
  5. Registers disconnect handler on the socket
  6. Registers authenticate handler via registerAuthHandler()

onClientAuthenticated()

typescript
onClientAuthenticated(opts: { socket: IOSocket }): void

Called after successful authentication. This method is public so it can be invoked externally for testing or custom auth flows.

Behavior:

  1. Validates the socket and client entry exist
  2. Sets client state to AUTHENTICATED
  3. Sends an initial ping
  4. Joins default rooms (io-default, io-notification)
  5. Registers room handlers (join, leave)
  6. Starts the ping interval
  7. Emits authenticated event to the client with { id, time }
  8. Invokes the clientConnectedFn callback (if configured)

Messaging via send()

The send() method uses the Redis emitter for message delivery, enabling cross-instance broadcasting:

typescript
send(opts: {
  destination?: string;    // Socket ID, room name, or omit for broadcast
  payload: {
    topic: string;         // Event name
    data: any;             // Event payload
  };
  doLog?: boolean;         // Log the emission (default: false)
  cb?: () => void;         // Callback executed via setImmediate after emit
})

Key behaviors:

  • All messages are compressed via emitter.compress(true)
  • If destination is provided and non-empty, sends via sender.to(destination).emit(topic, data)
  • If destination is omitted/empty, broadcasts to all connected clients via sender.emit(topic, data)
  • Callback (cb) is executed asynchronously via setImmediate(), not after delivery confirmation
  • Logging is opt-in (doLog: true) to avoid noise in high-throughput scenarios

send() Silent Failure Behavior

The send() method silently returns (no error, no log) in these cases:

  • payload is falsy
  • payload.topic is falsy
  • payload.data is falsy

This is a deliberate design choice for fire-and-forget messaging patterns where callers do not need to know if a message was dropped due to missing fields.

TIP

The emitter uses the Redis emitter client, so messages are delivered across all server instances in a horizontally-scaled deployment. This works even if the recipient is connected to a different server instance.

Shutdown

typescript
shutdown(): Promise<void>

Gracefully shuts down the server:

  1. Iterates all tracked clients and clears their intervals/timeouts
  2. Disconnects each client socket
  3. Clears the client map
  4. Closes the IO server (async, wrapped in a Promise)
  5. Quits all 3 Redis connections (redisPub, redisSub, redisEmitter)

Client Helper API Reference

SocketIOClientHelper extends BaseHelper and provides a managed Socket.IO client. It wraps the socket.io-client library with lifecycle callbacks, error-safe event subscription, and authentication state tracking.

Constructor

typescript
constructor(opts: ISocketIOClientOptions)

ISocketIOClientOptions Interface

typescript
interface ISocketIOClientOptions {
  identifier: string;
  host: string;
  options: IOptions;

  // Lifecycle callbacks (all optional)
  onConnected?: () => ValueOrPromise<void>;
  onDisconnected?: (reason: string) => ValueOrPromise<void>;
  onError?: (error: Error) => ValueOrPromise<void>;
  onAuthenticated?: () => ValueOrPromise<void>;
  onUnauthenticated?: (message: string) => ValueOrPromise<void>;
}

IOptions Interface

typescript
interface IOptions extends SocketOptions {
  path: string;
  extraHeaders: Record<string | symbol | number, any>;
}

IOptions extends SocketOptions from socket.io-client with two required fields:

  • path -- the Socket.IO endpoint path (must match the server's path option, e.g., '/io')
  • extraHeaders -- headers sent with every request, commonly used for authorization tokens

Constructor Behavior

  1. Calls super({ scope: opts.identifier }) to initialize BaseHelper with scoped logging
  2. Stores the identifier, host, options, and all lifecycle callbacks
  3. Immediately calls configure() to create the socket and register internal handlers

configure()

typescript
configure(): void

Creates the socket.io-client Socket instance and registers all internal event handlers. If the client is already established (i.e., configure() was already called), logs a message and returns early.

Registered handlers:

EventInternal Behavior
connectLogs connection, invokes onConnected callback
disconnectLogs disconnection with reason, resets state to unauthorized, invokes onDisconnected callback
connect_errorLogs the error, invokes onError callback
authenticatedLogs auth data, sets state to authenticated, invokes onAuthenticated callback
unauthenticatedLogs warning with auth data, resets state to unauthorized, invokes onUnauthenticated callback with the message
pingLogs debug-level ping received

All lifecycle callbacks are wrapped in Promise.resolve(...).catch(...) to prevent callback errors from crashing the client.

getState()

typescript
getState(): TSocketIOClientState

Returns the current authentication state: 'unauthorized', 'authenticating', or 'authenticated'.

TSocketIOClientState Type

typescript
type TSocketIOClientState = TConstValue<typeof SocketIOClientStates>;
// Resolves to: 'unauthorized' | 'authenticating' | 'authenticated'

getSocketClient()

typescript
getSocketClient(): Socket

Returns the raw socket.io-client Socket instance. Use this for direct access to Socket.IO client APIs not exposed by the helper (e.g., socket.io, socket.connected, socket.id).

authenticate()

typescript
authenticate(): void

Initiates the authentication handshake by emitting the authenticate event to the server. The server will validate credentials from the socket handshake (headers, query, auth object) and respond with authenticated or unauthenticated.

Guard conditions (no-op with warning log):

  • Socket is not connected (!this.client?.connected)
  • Current state is not unauthorized (prevents double-auth or re-auth while authenticating)

On call:

  1. Sets state to authenticating
  2. Emits SocketIOConstants.EVENT_AUTHENTICATE (value: 'authenticate')

subscribe()

typescript
subscribe<T = unknown>(opts: {
  event: string;
  handler: TSocketIOEventHandler<T>;
  ignoreDuplicate?: boolean;  // default: true
}): void

Subscribes to a Socket.IO event with automatic error safety.

Guard conditions (no-op with warning log):

  • handler is falsy
  • ignoreDuplicate is true (default) and the event already has listeners

Handler Wrapping Pattern

Handlers are wrapped in a dual try-catch that catches both synchronous throws and asynchronous rejections:

typescript
const wrappedHandler = (data: T) => {
  try {
    Promise.resolve(handler(data)).catch(error => {
      logger.error('Handler error | event: %s | error: %s', event, error);
    });
  } catch (error) {
    logger.error('Handler error | event: %s | error: %s', event, error);
  }
};

The outer try-catch handles synchronous throws from the handler. The .catch() on Promise.resolve() handles async rejections. This ensures handler errors never crash the client.

TSocketIOEventHandler<T> Type

typescript
type TSocketIOEventHandler<T = unknown> = (data: T) => ValueOrPromise<void>;

Handlers can be synchronous (void) or asynchronous (Promise<void>). Both are handled correctly by the wrapping pattern.

subscribeMany()

typescript
subscribeMany(opts: {
  events: Record<string, TSocketIOEventHandler>;
  ignoreDuplicate?: boolean;
}): void

Batch subscribes to multiple events. Iterates over the events record and calls subscribe() for each entry.

unsubscribe()

typescript
unsubscribe(opts: { event: string; handler?: TSocketIOEventHandler }): void

Removes event listeners. If handler is provided, removes only that specific handler via socket.off(event, handler). If handler is omitted, removes all handlers for the event via socket.off(event).

No-op if the socket has no listeners for the event.

unsubscribeMany()

typescript
unsubscribeMany(opts: { events: string[] }): void

Removes all handlers for each event in the array. Calls unsubscribe({ event }) for each entry.

connect()

typescript
connect(): void

Manually connects the socket. No-op with an info log if the client is not initialized. Useful when autoConnect: false is set in the options.

disconnect()

typescript
disconnect(): void

Manually disconnects the socket. No-op with an info log if the client is not initialized.

emit()

typescript
emit<T = unknown>(opts: {
  topic: string;
  data: T;
  doLog?: boolean;   // default: false
  cb?: () => void;
}): void

Emits an event to the server.

Throws (via getError()) if:

  • The socket is not connected (statusCode: 400, message: "Invalid socket client state to emit")
  • The topic is falsy (statusCode: 400, message: "Topic is required to emit")

If cb is provided, it is executed via setImmediate() (asynchronously, not after server acknowledgment). If doLog is true, logs the topic and data.

joinRooms()

typescript
joinRooms(opts: { rooms: string[] }): void

Emits a join event to the server with { rooms }. The server will validate via validateRoomFn and perform the actual join.

No-op with warning log if the socket is not connected.

leaveRooms()

typescript
leaveRooms(opts: { rooms: string[] }): void

Emits a leave event to the server with { rooms }. The server performs the actual leave without validation.

No-op with warning log if the socket is not connected.

shutdown()

typescript
shutdown(): void

Clean shutdown of the client:

  1. Calls removeAllListeners() on the underlying socket to prevent memory leaks
  2. Disconnects if still connected
  3. Resets state to unauthorized

Internals

resolveBindings()

Reads all binding keys from the DI container and validates required ones:

BindingValidationError on Failure
SERVER_OPTIONSOptional, merged with defaults via Object.assign()--
REDIS_CONNECTIONMust be instanceof DefaultRedisHelper`"Invalid instance of redisConnection
AUTHENTICATE_HANDLERMust be a function (non-null)"[DANGER][SocketIOComponent] Invalid authenticateFn to setup io socket server!"
VALIDATE_ROOM_HANDLEROptional, resolved from container, null coerced to undefined--
CLIENT_CONNECTED_HANDLEROptional, resolved from container, null coerced to undefined--

registerBunHook()

Registers a post-start hook that:

  1. Calls createBunEngine({ serverOptions }) which dynamically imports @socket.io/bun-engine and creates a BunEngine instance with CORS config bridging
  2. Creates SocketIOServerHelper with runtime: RuntimeModules.BUN
  3. Awaits socketIOHelper.configure() which waits for all Redis connections to be ready before initializing the adapter and emitter
  4. Binds the helper to SOCKET_IO_INSTANCE
  5. Gets the Bun server instance and Hono server, then calls serverInstance.reload() to wire the engine's fetch and websocket handlers into the running Bun server

createBunEngine() Function

typescript
async function createBunEngine(opts: {
  serverOptions: Partial<ServerOptions>;
}): Promise<{ engine: any; engineHandler: any }>

Dynamically imports @socket.io/bun-engine, creates a BunEngine with CORS bridging, and returns both the engine and the engineHandler (from engine.handler()). The engineHandler provides the websocket handler that Bun's server needs.

createBunFetchHandler() Function

typescript
function createBunFetchHandler(opts: {
  engine: any;
  enginePath: string;
  honoServer: OpenAPIHono;
}): (req: Request, server: TBunServerInstance) => Response | Promise<Response>

Returns a fetch handler function that routes requests:

  • If url.pathname starts with enginePath, delegates to engine.handleRequest(req, server) (returns 404 Response if handleRequest returns nullish)
  • Otherwise, delegates to honoServer.fetch(req, server) for normal Hono routing

registerNodeHook()

Registers a post-start hook that:

  1. Gets the HTTP server instance via getServerInstance()
  2. Validates the server instance exists (throws "[SocketIOComponent] HTTP server not available for Node.js runtime!" if not)
  3. Calls createNodeSocketIOHelper() which creates SocketIOServerHelper with runtime: RuntimeModules.NODE and the HTTP server, then awaits configure()
  4. Binds the helper to SOCKET_IO_INSTANCE

Node mode is simpler because Socket.IO natively attaches to node:http.Server.

Redis 3-Client Architecture

The server helper creates 3 independent Redis connections from a single DefaultRedisHelper:

RedisHelper (parent -- NOT consumed)
  |
  +-- client.duplicate() --> redisPub    (for Redis adapter -- publishes)
  |
  +-- client.duplicate() --> redisSub    (for Redis adapter -- subscribes)
  |
  +-- client.duplicate() --> redisEmitter (for @socket.io/redis-emitter -- message delivery)

Why 3 clients?

  • @socket.io/redis-adapter requires separate pub and sub clients because a Redis connection in subscribe mode cannot execute other commands
  • @socket.io/redis-emitter uses its own client to emit messages independently of the adapter, enabling cross-instance broadcasting even from contexts without a direct Socket.IO reference
  • The parent RedisHelper connection remains independent and is not consumed -- it can be used for other purposes (e.g., caching, sessions)

TRedisClient type:

typescript
type TRedisClient = Redis | Cluster;

This supports both single-instance Redis and Cluster connections from ioredis, making the helper transparent to the Redis deployment topology.

setRuntime() -- Runtime Validation

The private setRuntime() method validates the constructor options based on the runtime discriminant:

RuntimeRequired FieldError on Missing
RuntimeModules.NODEopts.server (HTTPServer)"Invalid HTTP server for Node.js runtime!"
RuntimeModules.BUNopts.engine (BunEngine)"Invalid @socket.io/bun-engine instance for Bun runtime!"
Other--"Unsupported runtime!"

initRedisClients() -- Redis Initialization

typescript
private initRedisClients(redisConnection: TSocketIOServerOptions['redisConnection']): void

Throws if redisConnection is falsy: "Invalid redis connection to config socket.io adapter!"

Creates 3 duplicated clients from the parent connection's underlying ioredis client.

initIOServer() -- IO Server Initialization

Called during configure() after Redis connections are ready:

RuntimeInitialization
RuntimeModules.NODEthis.io = new IOServer(this.server, this.serverOptions)
RuntimeModules.BUNthis.io = new IOServer() then this.io.bind(this.bunEngine)
OtherThrows "Unsupported runtime: <runtime>"

Additional validation errors:

  • Node.js without this.server: "[DANGER] Invalid HTTP server instance to init Socket.io server!"
  • Bun without this.bunEngine: "[DANGER] Invalid @socket.io/bun-engine instance to init Socket.io server!"

Connection Lifecycle

When a client connects, the server manages a strict authentication flow:

Client connects
  |
  +-- onClientConnect({ socket })
  |     +-- Validate socket exists and not duplicate
  |     +-- Create ISocketIOClient entry (state: UNAUTHORIZED)
  |     +-- Start authenticateTimeout (10s default)
  |     +-- Register 'disconnect' handler
  |     +-- Register 'authenticate' handler
  |
  +-- Client emits 'authenticate'
  |     +-- Validate client exists and state is UNAUTHORIZED
  |     +-- Set state to AUTHENTICATING
  |     +-- Call authenticateFn(handshake)
  |           +-- Success -> onClientAuthenticated()
  |           |     +-- Set state to AUTHENTICATED
  |           |     +-- Send initial ping
  |           |     +-- Join default rooms (io-default, io-notification)
  |           |     +-- Register 'join' and 'leave' room handlers
  |           |     +-- Start ping interval (30s default)
  |           |     +-- Emit 'authenticated' with { id, time }
  |           |     +-- Call clientConnectedFn({ socket }) if provided
  |           +-- Failure -> emit 'unauthenticated' -> disconnect
  |
  +-- Timeout (10s) -> disconnect if not AUTHENTICATED

Authentication Failure -- Two Code Paths

The registerAuthHandler() method handles authentication results through two distinct code paths:

Path 1: authenticateFn returns false (.then() handler):

  • Sets client state back to UNAUTHORIZED
  • Sends unauthenticated event with message: "Invalid token to authenticate! Please login again!"
  • Disconnects after send via setImmediate callback
  • No error logging (this is an expected outcome)

Path 2: authenticateFn throws an error (.catch() handler):

  • Sets client state back to UNAUTHORIZED
  • Logs the error at error level
  • Sends unauthenticated event with message: "Failed to authenticate connection! Please login again!"
  • Sets doLog: true on the send call (unlike Path 1)
  • Disconnects after send via setImmediate callback

Both paths also handle the edge case where the client disconnected during authentication -- they check this.clients.has(id) before proceeding.

ISocketIOClient Interface

typescript
interface ISocketIOClient {
  id: string;
  socket: IOSocket;
  state: TSocketIOClientState;              // 'unauthorized' | 'authenticating' | 'authenticated'
  interval?: NodeJS.Timeout;                 // Ping interval (set after auth)
  authenticateTimeout: NodeJS.Timeout;       // Auth deadline (cleared on success)
}

Room Handlers

Room join/leave handlers are registered after successful authentication:

  • join: Client emits { rooms: string[] }. If validateRoomFn is configured, only the rooms it returns are joined. If validateRoomFn is not configured, join is silently rejected with a warning log.
  • leave: Client emits { rooms: string[] }. Leave is always allowed -- no validation function needed.

Both handlers parse the payload defensively: const { rooms = [] } = payload || { rooms: [] }. Empty rooms arrays are silently ignored.

Join handler validation errors are caught and logged but do not disconnect the client.

WARNING

Without a validateRoomFn bound, clients cannot join any custom rooms. They will only be in the default rooms (io-default, io-notification). This is a security-by-default design.

Types Reference

Server Types

typescript
// Server constructor options -- discriminated union on 'runtime'
type TSocketIOServerOptions = ISocketIOServerNodeOptions | ISocketIOServerBunOptions;

// Base options shared by both runtimes
interface ISocketIOServerBaseOptions {
  identifier: string;
  serverOptions: Partial<ServerOptions>;
  redisConnection: DefaultRedisHelper;
  defaultRooms?: string[];
  authenticateTimeout?: number;
  pingInterval?: number;
  authenticateFn: TSocketIOAuthenticateFn;
  validateRoomFn?: TSocketIOValidateRoomFn;
  clientConnectedFn?: TSocketIOClientConnectedFn;
}

// Node.js runtime variant
interface ISocketIOServerNodeOptions extends ISocketIOServerBaseOptions {
  runtime: typeof RuntimeModules.NODE;
  server: HTTPServer;
}

// Bun runtime variant
interface ISocketIOServerBunOptions extends ISocketIOServerBaseOptions {
  runtime: typeof RuntimeModules.BUN;
  engine: any;
}

// Tracked client entry (server-side)
interface ISocketIOClient {
  id: string;
  socket: IOSocket;
  state: TSocketIOClientState;
  interval?: NodeJS.Timeout;
  authenticateTimeout: NodeJS.Timeout;
}

// Redis client type alias
type TRedisClient = Redis | Cluster;

Client Types

typescript
// Client constructor options
interface ISocketIOClientOptions {
  identifier: string;
  host: string;
  options: IOptions;
  onConnected?: () => ValueOrPromise<void>;
  onDisconnected?: (reason: string) => ValueOrPromise<void>;
  onError?: (error: Error) => ValueOrPromise<void>;
  onAuthenticated?: () => ValueOrPromise<void>;
  onUnauthenticated?: (message: string) => ValueOrPromise<void>;
}

// Socket connection options (extends socket.io-client's SocketOptions)
interface IOptions extends SocketOptions {
  path: string;
  extraHeaders: Record<string | symbol | number, any>;
}

// Event handler type (supports sync and async)
type TSocketIOEventHandler<T = unknown> = (data: T) => ValueOrPromise<void>;

// Client state type
type TSocketIOClientState = TConstValue<typeof SocketIOClientStates>;
// Resolves to: 'unauthorized' | 'authenticating' | 'authenticated'

Callback Types

typescript
// Server authentication handler
type TSocketIOAuthenticateFn = (args: IHandshake) => ValueOrPromise<boolean>;

// Server room validation handler
type TSocketIOValidateRoomFn = (opts: {
  socket: IOSocket;
  rooms: string[];
}) => ValueOrPromise<string[]>;

// Server client connected handler
type TSocketIOClientConnectedFn = (opts: { socket: IOSocket }) => ValueOrPromise<void>;

Component Types

typescript
// Extended ServerOptions with identifier
interface IServerOptions extends ServerOptions {
  identifier: string;
}

// Resolved binding values from DI container
interface IResolvedBindings {
  redisConnection: DefaultRedisHelper;
  authenticateFn: TSocketIOAuthenticateFn;
  validateRoomFn?: TSocketIOValidateRoomFn;
  clientConnectedFn?: TSocketIOClientConnectedFn;
}

Post-Start Hook System

The Socket.IO component uses post-start hooks to solve a timing problem: Socket.IO needs a running server, but components initialize before the server starts.

The component relies on AbstractApplication's post-start hook system:

API

typescript
// Register a hook (during binding phase)
application.registerPostStartHook({
  identifier: string,                        // Unique name for logging
  hook: () => ValueOrPromise<void>,          // Async function to execute
});

// Get the server instance (available after start)
application.getServerInstance<T>(): T | undefined;

Hook Execution Flow

Application.start()
  |
  +-- Bun.serve() / serve()   <-- Server created
  |
  +-- executePostStartHooks()  <-- Hooks run here
        |
        +-- SocketIOComponent hook:
              1. Get server instance via getServerInstance()
              2. Create SocketIOServerHelper with runtime-specific options
              3. Call helper.configure() to initialize Socket.IO server
              4. Bind the helper instance for injection

Detailed Hook Timing

executePostStartHooks()
  |-- Hook 1: "socket-io-initialize"
  |     |-- performance.now() -> start
  |     |-- await hook()
  |     +-- log: "Executed hook | identifier: socket-io-initialize | took: 12.5 (ms)"
  |-- Hook 2: "another-hook"
  |     +-- ...
  +-- (hooks run sequentially in registration order)
  • Hooks run sequentially (not parallel) to guarantee ordering
  • Each hook is timed with performance.now() for diagnostics
  • If a hook throws, it propagates to start() and the server fails to start

What Happens Inside the Hook

For Bun runtime, the hook:

  1. Calls createBunEngine({ serverOptions }) which dynamically imports @socket.io/bun-engine and creates a BunEngine instance with CORS config bridging
  2. Creates SocketIOServerHelper with runtime: RuntimeModules.BUN and the engine
  3. Awaits socketIOHelper.configure() which connects Redis pub/sub/emitter clients, initializes the IOServer, and sets up the Redis adapter
  4. Binds the helper to SOCKET_IO_INSTANCE
  5. Gets the Bun server instance and Hono server
  6. Calls serverInstance.reload({ fetch, websocket }) to wire the engine's fetch and websocket handlers into the running Bun server, where fetch is the result of createBunFetchHandler() and websocket is from engineHandler.websocket

For Node.js runtime, the hook:

  1. Gets the HTTP server instance via getServerInstance()
  2. Validates the server instance exists (throws if not)
  3. Calls createNodeSocketIOHelper() which creates SocketIOServerHelper with runtime: RuntimeModules.NODE and the HTTP server, then awaits configure()
  4. Binds the helper to SOCKET_IO_INSTANCE

NOTE

The hook identifier is 'socket-io-initialize' for both runtimes. Only one runtime path executes per application.

Graceful Shutdown

Always shut down the Socket.IO server before stopping the application:

Shutdown Implementation

typescript
override async stop(): Promise<void> {
  // 1. Shut down Socket.IO (disconnects all clients, closes IO server, quits Redis)
  const socketIOHelper = this.get<SocketIOServerHelper>({
    key: SocketIOBindingKeys.SOCKET_IO_INSTANCE,
    isOptional: true,
  });

  if (socketIOHelper) {
    await socketIOHelper.shutdown();
  }

  // 2. Disconnect Redis helper
  if (this.redisHelper) {
    await this.redisHelper.disconnect();
  }

  // 3. Stop the HTTP/Bun server
  await super.stop();
}

Shutdown Flow

socketIOHelper.shutdown()
  |-- Disconnect all tracked clients
  |     |-- clearInterval(ping)
  |     |-- clearTimeout(authenticateTimeout)
  |     +-- socket.disconnect()
  |-- clients.clear()
  |-- io.close() -- closes the Socket.IO server (async)
  +-- Redis cleanup
        |-- redisPub.quit()
        |-- redisSub.quit()
        +-- redisEmitter.quit()

Client helper shutdown:

clientHelper.shutdown()
  |-- removeAllListeners() -- prevents memory leaks
  |-- disconnect() -- if still connected
  +-- state = UNAUTHORIZED

See Also