Skip to content

Socket.IO Integration

KickJS ships with a ws-based WebSocket adapter (@forinda/kickjs-ws), but you can integrate Socket.IO for features like automatic reconnection, rooms, acknowledgements, and binary support.

Setup

bash
pnpm add socket.io

Create a Socket.IO Adapter

ts
// src/adapters/socketio.adapter.ts
import { Server, type Socket } from 'socket.io'
import {
  createToken,
  defineAdapter,
  Logger,
  type AdapterContext,
  type Container,
} from '@forinda/kickjs'

const log = Logger.for('SocketIOAdapter')

export interface SocketIOAdapterOptions {
  /** CORS configuration */
  cors?: {
    origin: string | string[]
    methods?: string[]
    credentials?: boolean
  }
  /** Path for the Socket.IO endpoint (default: '/socket.io') */
  path?: string
  /** Custom namespaces to register */
  namespaces?: SocketIONamespace[]
}

export interface SocketIONamespace {
  /** Namespace path (e.g. '/chat', '/notifications') */
  namespace: string
  /** Handler setup function — receives the namespace and DI container */
  setup: (nsp: any, container: Container) => void
}

/**
 * Typed DI token for injecting the Socket.IO server.
 * `container.resolve(SOCKET_IO)` returns `Server` without a manual generic.
 */
export const SOCKET_IO = createToken<Server>('kick/socketio/server')

export const SocketIOAdapter = defineAdapter<SocketIOAdapterOptions>({
  name: 'SocketIOAdapter',
  defaults: { path: '/socket.io' },
  build: (options) => {
    let io: Server | undefined

    return {
      afterStart({ server, container }: AdapterContext): void {
        io = new Server(server, {
          cors: options.cors ?? { origin: '*' },
          path: options.path,
        })

        // Default namespace
        io.on('connection', (socket: Socket) => {
          log.info(`Connected: ${socket.id}`)
          socket.on('disconnect', (reason) => {
            log.info(`Disconnected: ${socket.id} (${reason})`)
          })
        })

        // Custom namespaces
        for (const ns of options.namespaces ?? []) {
          const nsp = io.of(ns.namespace)
          ns.setup(nsp, container)
          log.info(`Namespace registered: ${ns.namespace}`)
        }

        // Register io instance in DI for injection
        container.registerInstance(SOCKET_IO, io)
        log.info(`Socket.IO listening at ${options.path}`)
      },

      async shutdown(): Promise<void> {
        if (io) {
          await new Promise<void>((resolve) => io!.close(() => resolve()))
          log.info('Socket.IO server closed')
        }
      },
    }
  },
})

Register in Bootstrap

ts
import { bootstrap } from '@forinda/kickjs'
import { SocketIOAdapter } from './adapters/socketio.adapter'
import { modules } from './modules'

bootstrap({
  modules,
  adapters: [
    SocketIOAdapter({
      cors: { origin: 'http://localhost:5173', credentials: true },
      namespaces: [
        {
          namespace: '/chat',
          setup: (nsp, container) => {
            nsp.on('connection', (socket) => {
              console.log(`Chat connected: ${socket.id}`)

              socket.on('message', (data) => {
                // Broadcast to room or all
                nsp.emit('message', {
                  from: socket.id,
                  ...data,
                  timestamp: new Date().toISOString(),
                })
              })

              socket.on('join-room', (room) => {
                socket.join(room)
                socket.to(room).emit('user-joined', { userId: socket.id })
              })

              socket.on('leave-room', (room) => {
                socket.leave(room)
                socket.to(room).emit('user-left', { userId: socket.id })
              })
            })
          },
        },
        {
          namespace: '/notifications',
          setup: (nsp, container) => {
            nsp.on('connection', (socket) => {
              // Join user-specific room for targeted notifications
              const userId = socket.handshake.auth?.userId
              if (userId) socket.join(`user:${userId}`)
            })
          },
        },
      ],
    }),
  ],
})

Inject Socket.IO in Services

Use the SOCKET_IO token to inject the io server anywhere:

ts
import { Service, Inject } from '@forinda/kickjs'
import { SOCKET_IO } from '../adapters/socketio.adapter'
import type { Server } from 'socket.io'

@Service()
export class NotificationPushService {
  constructor(@Inject(SOCKET_IO) private io: Server) {}

  /** Send a notification to a specific user */
  notifyUser(userId: string, event: string, data: any) {
    this.io.of('/notifications').to(`user:${userId}`).emit(event, data)
  }

  /** Broadcast to all connected clients */
  broadcast(event: string, data: any) {
    this.io.emit(event, data)
  }

  /** Send to a specific room */
  toRoom(room: string, event: string, data: any) {
    this.io.to(room).emit(event, data)
  }
}

Client-Side

ts
import { io } from 'socket.io-client'

// Connect to default namespace
const socket = io('http://localhost:3000')

// Connect to a specific namespace
const chat = io('http://localhost:3000/chat')
const notifications = io('http://localhost:3000/notifications', {
  auth: { userId: 'user-123' },
})

// Listen for events
chat.on('message', (msg) => console.log('New message:', msg))
notifications.on('alert', (alert) => console.log('Alert:', alert))

// Send events
chat.emit('message', { text: 'Hello everyone!' })
chat.emit('join-room', 'general')

Socket.IO vs ws

@forinda/kickjs-wsSocket.IO
ProtocolRaw WebSocketCustom protocol over WebSocket/polling
ReconnectionManualAutomatic
RoomsVia RoomManagerBuilt-in
AcknowledgementsManualBuilt-in callbacks
BinaryManualAutomatic
FallbackWebSocket onlyLong-polling fallback
Bundle size~50KB~300KB (client + server)
Decorators@WsController, @OnMessageUse adapter pattern above
Best forLightweight, low-level controlFull-featured real-time apps

With Authentication

ts
// Middleware for Socket.IO authentication
io.use((socket, next) => {
  const token = socket.handshake.auth?.token
  if (!token) return next(new Error('Authentication required'))

  try {
    const user = jwt.verify(token, JWT_SECRET)
    socket.data.user = user
    next()
  } catch {
    next(new Error('Invalid token'))
  }
})

// Access user in handlers
io.on('connection', (socket) => {
  console.log(`Authenticated user: ${socket.data.user.email}`)
})

With KickJS Auth

If you're using @forinda/kickjs-auth, you can reuse your JWT strategy:

ts
import { JwtStrategy } from '@forinda/kickjs-auth'

const jwtStrategy = JwtStrategy({ secret: JWT_SECRET })

io.use(async (socket, next) => {
  // Create a mock request object for the strategy
  const mockReq = {
    headers: { authorization: `Bearer ${socket.handshake.auth?.token}` },
  }
  const user = await jwtStrategy.validate(mockReq)
  if (!user) return next(new Error('Unauthorized'))
  socket.data.user = user
  next()
})