Realtime with WebSocket
Shared Socket.IO gateway, JWT handshake auth, and message handlers keyed by type.
Overview
The stack under src/common/websocket exposes a single Socket.IO gateway. Features (chat, notifications, etc.) do not create another gateway: they register MessageHandlers that respond to payload.type. The reference module is websocket-example.
Key ideas
- • One shared gateway at path /ws
- • Single Socket.IO event: message
- • Routing by payload.type → MessageHandler
- • JWT required on the connection handshake
Architecture and flow
AppModule imports WebsocketModule.forRoot(), which boots WebsocketGateway and WebsocketService. Each feature module imports WebsocketModule and registers its handlers.
Message path
Client connects to /ws with JWT
Client emits message with { type, ... }
WebsocketGateway @SubscribeMessage('message')
WebsocketService.processMessage finds the handler
Handler validates the DTO and calls the feature service
Gateway returns messageResponse { received: true }
// app.module.ts imports: [ WebsocketModule.forRoot(), WebsocketExampleModule, // feature modules // MyFeatureModule, ]
JWT authentication
In AbstractWebsocketGateway.afterInit, a Socket.IO middleware requires a JWT. JwtAuthGuard and RolesGuard also work on the WS gateway. Handlers may declare roles for per-message authorization.
- • Token in socket.handshake.auth.token
- • Or Authorization: Bearer <token> header
- • Verified with jwt.secret (ConfigService)
- • decoded.id / email / roles → socket.data.user
- • Optional handler.roles — e.g. websocket-example requires Role.SUPER
const socket = io('http://localhost:3000', {
path: '/ws',
auth: { token: accessToken },
// or: extraHeaders: { Authorization: `Bearer ${accessToken}` }
});
Message contract
All feature traffic uses the message event. The type field selects the handler (first-match).
Interfaces
export interface MessagePayload {
type: string;
[key: string]: any;
}
export interface MessageHandler {
canHandle(type: string): boolean;
handle(client: Socket, payload: MessagePayload): void;
}Events
- • message (client → server) — payload with type
- • messageResponse (server → client) — gateway ack
- • error — validation failed (example pattern)
- • Feature events — you define them (e.g. websocket_example_ack)
WebsocketService API
The concrete service extends AbstractWebsocketService and is the registration/broadcast entry point.
- •
registerMessageHandler(handler) — register a handler - •
addClient / removeClient / getClient / getAllClients - •
sendToClient(clientId, event, data) - •
broadcast(event, data, except?) - •
processMessage(client, payload) — first handler with canHandle(type)
Create a WebSocket module
Follow these steps for a full feature. You do not need a new @WebSocketGateway.
Folder structure
src/modules/my-feature/
├── my-feature.module.ts
├── my-feature.service.ts
├── dtos/
│ └── my-feature.dto.ts
└── handlers/
└── my-feature.handler.ts
1. DTO
Include a stable type and validate with class-validator.
export class MyFeatureDto {
@IsString()
@IsNotEmpty()
type: string = 'my_feature'; // stable type — routing key
@IsString()
@IsNotEmpty()
message!: string;
}
2. Handler
Implement MessageHandler: canHandle + validate + service.
@Injectable()
export class MyFeatureHandler implements MessageHandler {
constructor(private readonly myFeatureService: MyFeatureService) {}
canHandle(type: string): boolean {
return type === 'my_feature';
}
async handle(client: Socket, payload: MessagePayload) {
const dto = Object.assign(new MyFeatureDto(), payload);
const errors = await validate(dto);
if (errors.length > 0) {
client.emit('error', { message: 'Validation failed', errors });
return;
}
this.myFeatureService.processMessage(client, dto);
}
}
3. Service
Business logic; use client.emit or WebsocketService.sendToClient / broadcast.
@Injectable()
export class MyFeatureService {
processMessage(client: Socket, dto: MyFeatureDto): void {
client.emit('my_feature_ack', {
received: true,
timestamp: new Date().toISOString(),
});
}
}
4. Module + registration
Import WebsocketModule and register the handler with the REGISTER_HANDLERS factory.
@Module({
imports: [WebsocketModule],
providers: [
MyFeatureService,
MyFeatureHandler,
{
provide: 'REGISTER_HANDLERS',
useFactory: (ws: WebsocketService, handler: MyFeatureHandler) => {
// register the handler at module bootstrap
ws.registerMessageHandler(handler);
return true;
},
inject: [WebsocketService, MyFeatureHandler],
},
],
exports: [MyFeatureService],
})
export class MyFeatureModule {}
5. AppModule
Ensure WebsocketModule.forRoot() and import your feature module.
Multiple DTOs in the same module
Recommended pattern: one DTO and one Handler per type. Register all in the same factory. The example only has one DTO; the pattern below is what real features should use.
Pattern
- • CreateXDto with type: 'x_create'
- • UpdateXDto with type: 'x_update'
- • CreateXHandler.canHandle === 'x_create'
- • UpdateXHandler.canHandle === 'x_update'
- • REGISTER_HANDLERS calls registerMessageHandler for each
DTOs
export class ChatSendDto {
@IsString() @IsNotEmpty()
type: string = 'chat_send';
@IsString() @IsNotEmpty()
roomId!: string;
@IsString() @IsNotEmpty()
text!: string;
}
export class ChatTypingDto {
@IsString() @IsNotEmpty()
type: string = 'chat_typing';
@IsString() @IsNotEmpty()
roomId!: string;
@IsBoolean()
isTyping!: boolean;
}
Handlers
@Injectable()
export class ChatSendHandler implements MessageHandler {
canHandle(type: string) { return type === 'chat_send'; }
async handle(client: Socket, payload: MessagePayload) {
const dto = Object.assign(new ChatSendDto(), payload);
// validate(dto) ...
this.chatService.send(client, dto);
}
}
@Injectable()
export class ChatTypingHandler implements MessageHandler {
canHandle(type: string) { return type === 'chat_typing'; }
async handle(client: Socket, payload: MessagePayload) {
const dto = Object.assign(new ChatTypingDto(), payload);
// validate(dto) ...
this.chatService.typing(client, dto);
}
}
Module
{
provide: 'REGISTER_HANDLERS',
useFactory: (
ws: WebsocketService,
sendHandler: ChatSendHandler,
typingHandler: ChatTypingHandler,
) => {
// multiple handlers in the same module
ws.registerMessageHandler(sendHandler);
ws.registerMessageHandler(typingHandler);
return true;
},
inject: [WebsocketService, ChatSendHandler, ChatTypingHandler],
}
websocket-example module
Minimal demo: one DTO type example, one handler, in-memory ack, and a REST endpoint for online clients.
- • type: 'example' + required message field
- • Emits websocket_example_ack after processing
- • Emits error if validation fails
- • GET /websocket-example/online — clients active in the last 5s (JWT + Role.SUPER)
socket.emit('message', {
type: 'example',
message: 'hello from client',
});GET /api/v1/websocket-example/online Authorization: Bearer
Validation
Domain validation lives in the handler with class-validator. PayloadValidationPipe (requires type) exists under common/websocket/pipes but is not applied on the gateway today.
- • Handler: Object.assign(new Dto(), payload) + validate()
- • Failure → client.emit('error', { message, errors })
- • Optional pipe on the gateway if you want early type checks
Socket.IO client
Connect with path /ws and a JWT. Then emit message and listen for feature events.
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000', {
path: '/ws',
auth: { token: accessToken },
});
socket.on('connect', () => {
socket.emit('message', {
type: 'example',
message: 'hello',
});
});
socket.on('messageResponse', (data) => {
// { received: true }
});
socket.on('websocket_example_ack', (data) => {
// { received: true, timestamp: '...' }
});
socket.on('error', (err) => {
// { message: 'Validation failed', errors: [...] }
});Open the interactive playground at /wsui to connect and exchange messages without writing a client.
/wsuiNotes
JWT required
Without a valid handshake token the connection will not start. Get an access token via Auth.
Async handlers
processMessage does not await handle(). Async errors do not reject the gateway Promise — handle errors inside the handler.
In-memory state
The example ack map is per process. Across multiple instances use a shared store (Redis, etc.).