Skip to content

WebSockets

WebSockets provide a persistent, bidirectional communication channel between the client and the server. Unlike traditional HTTP request-response cycles, a WebSocket connection remains open, allowing for real-time data exchange—ideal for applications like chat systems, live notifications, collaborative editing, and multiplayer games.

In the Model-View-Controller (MVC) architectural pattern, a WebSocket Gateway serves a similar role to a Controller, but for persistent connections. It handles incoming WebSocket Messages and can push Server-Initiated Events at any time.

Concept: The Live Operator

While the Controller acts as a Waiter (serving one request at a time), the WebSocket Gateway is a Live Operator.

  • Maintains Connection: Keeps an open line with each client.
  • Listens Continuously: Awaits incoming messages at any moment.
  • Pushes Proactively: Can send data to the client without being asked.
  • Orchestrates Groups: Can manage groups of clients (rooms) for broadcasting.
  • Crucial Rule: Like Controllers, Gateways should remain Thin. Business logic belongs in Services.

Core Concepts

Gateways vs Controllers

FeatureControllerGateway
ProtocolHTTP (Request/Response)WebSocket (Bidirectional)
ConnectionShort-livedPersistent
CommunicationClient initiatesBoth can initiate
Use CaseREST APIs, CRUD operationsReal-time updates, chat, notifications
Decorator@Controller()@WebSocketGateway()

Message Protocol

Karin uses a simple JSON-based message protocol:

Client → Server:

json
{
  "event": "message_name",
  "data": { "your": "payload" },
  "id": "optional_ack_id"
}

Server → Client:

json
{
  "event": "message_name",
  "data": { "your": "response" }
}

Acknowledgment (ACK) Response:

json
{
  "id": "same_ack_id",
  "status": "ok",
  "data": { "result": "value" }
}

Defining a Gateway

A Gateway is defined using the @WebSocketGateway() decorator. This marks the class as a WebSocket handler that will be automatically registered by the framework.

typescript
import { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket } from '@project-karin/core';

@WebSocketGateway()
export class ChatGateway {
  
  @SubscribeMessage('message')
  handleMessage(
    @MessageBody() data: { content: string },
    @ConnectedSocket() client: any
  ) {
    console.log(`Received: ${data.content}`);
    return { received: true };
  }
}

Registering Gateways

Gateways are registered just like Controllers:

typescript
import { KarinFactory } from '@project-karin/core';
import { H3Adapter } from '@project-karin/platform-h3';
import { ChatGateway } from './chat/chat.gateway';

const app = await KarinFactory.create(new H3Adapter(), {
  controllers: [ChatGateway], // Gateways go in controllers array
});

app.listen(3000);

Message Handlers

The @SubscribeMessage() decorator maps an incoming event name to a handler method. When the server receives a message with a matching event field, the decorated method is invoked.

Basic Handler

typescript
@SubscribeMessage('ping')
handlePing() {
  return { event: 'pong', timestamp: Date.now() };
}

With Parameters

Use parameter decorators to extract specific parts of the message:

typescript
@SubscribeMessage('chat')
handleChat(
  @MessageBody() data: ChatMessage,
  @ConnectedSocket() client: any
) {
  console.log(`From ${client.id}: ${data.content}`);
  return { status: 'delivered' };
}

Available Parameter Decorators

DecoratorDescriptionExample Value
@MessageBody()The data field of the message{ content: "Hello" }
@ConnectedSocket()The raw WebSocket client object{ id, send(), join(), leave() }

Connection Lifecycle

Unlike HTTP, WebSocket connections have a lifecycle. Karin exposes lifecycle hooks as optional methods in your Gateway class.

handleConnection(client)

Called when a new client establishes a connection.

typescript
@WebSocketGateway()
export class NotificationGateway {
  
  handleConnection(client: any) {
    console.log(`Client connected: ${client.id}`);
    // Send welcome message
    client.send(JSON.stringify({
      event: 'welcome',
      data: { message: 'Connected to notification service' }
    }));
  }
}

handleDisconnect(client)

Called when a client disconnects (closes the browser, loses network, etc.).

typescript
handleDisconnect(client: any) {
  console.log(`Client disconnected: ${client.id}`);
  // Cleanup user session, remove from rooms, etc.
}

Lifecycle Flow

Client opens connection

   handleConnection()

   [Connection Active]

   @SubscribeMessage handlers process messages

   Client closes connection OR server terminates

   handleDisconnect()

Request-Response (Acknowledgments)

Sometimes clients need confirmation that their message was processed. Karin supports acknowledgments (ACKs) using message IDs.

Client Side

javascript
const messageId = crypto.randomUUID();

socket.send(JSON.stringify({
  event: 'save_settings',
  data: { theme: 'dark' },
  id: messageId  // This triggers ACK response
}));

socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.id === messageId && msg.status === 'ok') {
    console.log('Settings saved!', msg.data);
  }
};

Server Side

Simply return a value from your handler. If the incoming message has an id, Karin automatically wraps the result in an ACK response.

typescript
@SubscribeMessage('save_settings')
handleSaveSettings(@MessageBody() data: Settings) {
  const saved = this.settingsService.save(data);
  return { success: true, savedAt: new Date() };
  // Client receives: { id: "...", status: "ok", data: { success: true, savedAt: "..." } }
}

Dependency Injection

Gateways are fully integrated into Karin's DI system. You can inject Services, Repositories, or any other provider.

typescript
import { Service } from '@project-karin/core';

@Service()
export class MessagesService {
  private messages: Message[] = [];

  addMessage(content: string, userId: string) {
    const msg = { id: Date.now(), content, userId, createdAt: new Date() };
    this.messages.push(msg);
    return msg;
  }

  getRecent(limit = 50) {
    return this.messages.slice(-limit);
  }
}
typescript
@WebSocketGateway()
export class ChatGateway {
  // Service is automatically injected
  constructor(private readonly messagesService: MessagesService) {}

  @SubscribeMessage('send_message')
  handleMessage(
    @MessageBody() data: { content: string },
    @ConnectedSocket() client: any
  ) {
    const message = this.messagesService.addMessage(data.content, client.id);
    return message;
  }
}

Guards, Pipes, and Interceptors

WebSocket handlers support the same middleware pipeline as HTTP controllers.

Guards

Guards perform authorization checks before the handler executes.

typescript
import { Service, type CanActivate, type ExecutionContext } from '@project-karin/core';

@Service()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const wsContext = context.switchToWs();
    const client = wsContext.getClient();
    
    // Check if client is authenticated
    return client.isAuthenticated === true;
  }
}

Usage:

typescript
@UseGuards(AuthGuard)
@SubscribeMessage('private_message')
handlePrivateMessage(@MessageBody() data: any) {
  // Only executed if guard returns true
}

Pipes

Pipes validate and transform incoming data.

typescript
import { z } from 'zod';
import { createZodDto, ZodValidationPipe, UsePipes } from '@project-karin/core';

const ChatMessageSchema = z.object({
  content: z.string().min(1).max(500),
  roomId: z.string().uuid(),
});

class ChatMessageDto extends createZodDto(ChatMessageSchema) {}

@UsePipes(new ZodValidationPipe())
@SubscribeMessage('chat')
handleChat(@MessageBody() data: ChatMessageDto) {
  // data is validated and typed
}

Rooms and Broadcasting

For multi-room chat applications, Karin provides the optional @project-karin/websocket-rooms plugin. This adds Socket.io-like room management capabilities.

Installation

bash
bun add @project-karin/websocket-rooms

Setup

typescript
import { KarinFactory } from '@project-karin/core';
import { H3Adapter } from '@project-karin/platform-h3';
import { WsRoomsPlugin } from '@project-karin/websocket-rooms';
import { ChatGateway } from './chat.gateway';

const wsRooms = new WsRoomsPlugin({
  autoAssignId: true,   // Automatically assign unique IDs to clients
  idPrefix: 'user-',    // Prefix for generated IDs
});

const app = await KarinFactory.create(new H3Adapter(), {
  plugins: [wsRooms],
  controllers: [ChatGateway],
});

app.listen(3000);

Client Methods

With the plugin installed, each connected client gains these methods:

MethodDescription
client.idUnique identifier for this client
client.join(room)Join a room
client.leave(room)Leave a room
client.roomsSet of rooms the client is currently in

WsRoomsService

Inject WsRoomsService for broadcasting capabilities:

typescript
import { WsRoomsService } from '@project-karin/websocket-rooms';

@WebSocketGateway()
export class RoomsGateway {
  constructor(private readonly wsRooms: WsRoomsService) {}

  @SubscribeMessage('join_room')
  handleJoin(
    @MessageBody() data: { room: string },
    @ConnectedSocket() client: any
  ) {
    client.join(data.room);
    return { joined: data.room };
  }

  @SubscribeMessage('room_message')
  handleRoomMessage(
    @MessageBody() data: { room: string; content: string },
    @ConnectedSocket() client: any
  ) {
    // Broadcast to everyone in the room
    this.wsRooms.to(data.room).emit('new_message', {
      from: client.id,
      content: data.content,
      timestamp: Date.now(),
    });
  }
}

Broadcasting Methods

MethodDescription
wsRooms.to(room).emit(event, data)Send to all clients in a room
wsRooms.broadcast(sender, event, data)Send to all clients except sender
wsRooms.getRoomClients(room)Get Set of clients in a room
wsRooms.getClientRooms(client)Get Set of rooms a client is in
wsRooms.getStats()Get room statistics

Complete Example: Multi-Room Chat

Here's a full implementation of a multi-room chat system:

Gateway

typescript
// rooms.gateway.ts
import { 
  WebSocketGateway, 
  SubscribeMessage, 
  MessageBody, 
  ConnectedSocket 
} from '@project-karin/core';
import { WsRoomsService } from '@project-karin/websocket-rooms';

interface RoomMessage {
  room: string;
  content: string;
}

@WebSocketGateway()
export class RoomsGateway {
  constructor(private wsRooms: WsRoomsService) {}

  handleConnection(client: any) {
    console.log(`Client ${client.id} connected`);
  }

  handleDisconnect(client: any) {
    console.log(`Client ${client.id} disconnected`);
  }

  @SubscribeMessage('join_room')
  handleJoinRoom(
    @MessageBody() data: { room: string },
    @ConnectedSocket() client: any
  ) {
    client.join(data.room);
    
    // Notify client of their ID
    client.send(JSON.stringify({
      event: 'client_id',
      data: { id: client.id }
    }));
    
    // Notify room of new member
    this.wsRooms.to(data.room).emit('user_joined', {
      userId: client.id,
      room: data.room
    });
    
    return { success: true, room: data.room };
  }

  @SubscribeMessage('leave_room')
  handleLeaveRoom(
    @MessageBody() data: { room: string },
    @ConnectedSocket() client: any
  ) {
    client.leave(data.room);
    
    this.wsRooms.to(data.room).emit('user_left', {
      userId: client.id,
      room: data.room
    });
    
    return { success: true };
  }

  @SubscribeMessage('room_message')
  handleRoomMessage(
    @MessageBody() data: RoomMessage,
    @ConnectedSocket() client: any
  ): void {
    this.wsRooms.to(data.room).emit('room_message', {
      room: data.room,
      content: data.content,
      from: client.id,
      timestamp: Date.now()
    });
  }
}

Main Entry

typescript
// main.ts
import { KarinFactory } from '@project-karin/core';
import { H3Adapter } from '@project-karin/platform-h3';
import { WsRoomsPlugin } from '@project-karin/websocket-rooms';
import { RoomsGateway } from './rooms.gateway';

async function bootstrap() {
  const wsRooms = new WsRoomsPlugin();

  const app = await KarinFactory.create(new H3Adapter(), {
    plugins: [wsRooms],
    controllers: [RoomsGateway],
  });

  app.listen(3000, () => {
    console.log('WebSocket server running on http://localhost:3000');
  });
}

bootstrap();

Best Practices

1. Thin Gateways, Fat Services

Just like Controllers, Gateways should delegate business logic to Services.

Bad:

typescript
@SubscribeMessage('message')
handle(@MessageBody() data: any) {
  // Database query in gateway
  const user = db.query('SELECT * FROM users WHERE id = ?', [data.userId]);
  // Business logic in gateway
  if (user.banned) throw new Error('Banned');
  // More logic...
}

Good:

typescript
@SubscribeMessage('message')
handle(@MessageBody() data: any) {
  return this.chatService.processMessage(data);
}

2. Handle Disconnections Gracefully

Always implement handleDisconnect to clean up resources:

typescript
handleDisconnect(client: any) {
  this.onlineUsersService.remove(client.id);
  this.activeGamesService.removePlayer(client.id);
}

3. Validate All Input

Never trust client data. Use Pipes or manual validation:

typescript
@SubscribeMessage('message')
handle(@MessageBody() data: any) {
  if (!data.content || typeof data.content !== 'string') {
    throw new WsException('Invalid message format');
  }
  if (data.content.length > 1000) {
    throw new WsException('Message too long');
  }
  // Process...
}

4. Use Rooms for Segmentation

Instead of tracking clients manually, use the rooms plugin:

Bad:

typescript
private techClients: Set<any> = new Set();
private randomClients: Set<any> = new Set();

Good:

typescript
client.join('tech');
this.wsRooms.to('tech').emit('message', data);

5. Consider Connection Limits

For production, implement rate limiting and connection limits to prevent abuse.


Next Steps

Explore how to extend Karin's capabilities even further with the Plugin System.

Released under the MIT License.