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
| Feature | Controller | Gateway |
|---|---|---|
| Protocol | HTTP (Request/Response) | WebSocket (Bidirectional) |
| Connection | Short-lived | Persistent |
| Communication | Client initiates | Both can initiate |
| Use Case | REST APIs, CRUD operations | Real-time updates, chat, notifications |
| Decorator | @Controller() | @WebSocketGateway() |
Message Protocol
Karin uses a simple JSON-based message protocol:
Client → Server:
{
"event": "message_name",
"data": { "your": "payload" },
"id": "optional_ack_id"
}Server → Client:
{
"event": "message_name",
"data": { "your": "response" }
}Acknowledgment (ACK) Response:
{
"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.
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:
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
@SubscribeMessage('ping')
handlePing() {
return { event: 'pong', timestamp: Date.now() };
}With Parameters
Use parameter decorators to extract specific parts of the message:
@SubscribeMessage('chat')
handleChat(
@MessageBody() data: ChatMessage,
@ConnectedSocket() client: any
) {
console.log(`From ${client.id}: ${data.content}`);
return { status: 'delivered' };
}Available Parameter Decorators
| Decorator | Description | Example 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.
@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.).
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
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.
@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.
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);
}
}@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.
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:
@UseGuards(AuthGuard)
@SubscribeMessage('private_message')
handlePrivateMessage(@MessageBody() data: any) {
// Only executed if guard returns true
}Pipes
Pipes validate and transform incoming data.
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
bun add @project-karin/websocket-roomsSetup
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:
| Method | Description |
|---|---|
client.id | Unique identifier for this client |
client.join(room) | Join a room |
client.leave(room) | Leave a room |
client.rooms | Set of rooms the client is currently in |
WsRoomsService
Inject WsRoomsService for broadcasting capabilities:
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
| Method | Description |
|---|---|
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
// 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
// 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:
@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:
@SubscribeMessage('message')
handle(@MessageBody() data: any) {
return this.chatService.processMessage(data);
}2. Handle Disconnections Gracefully
Always implement handleDisconnect to clean up resources:
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:
@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:
private techClients: Set<any> = new Set();
private randomClients: Set<any> = new Set();Good:
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.
