Communication en temps réel : comment les WebSockets alimentent les fonctionnalités live
Les requêtes HTTP sont stateless — idéales pour récupérer des données, pas pour réagir aux événements en temps réel. Les WebSockets maintiennent une connexion persistante ouverte, c'est ainsi que nous alimentons le matchmaking live, les notifications et les indicateurs de présence sans polling constant.
NestJS offre une approche puissante basée sur les décorateurs pour le développement WebSocket. Les Gateways sont l'équivalent NestJS des contrôleurs, mais pour les événements WebSocket. Ce guide couvre la construction de fonctionnalités temps réel prêtes pour la production avec NestJS et Socket.io — des gateways de base aux guards d'authentification, pipes de validation, intercepteurs, tests et mise à l'échelle horizontale avec Redis.
Pourquoi NestJS pour les WebSockets ?
Bien que vous puissiez utiliser Socket.io brut avec Express, NestJS apporte de la structure aux applications temps réel grâce à son système de modules, l'injection de dépendances et les patterns de décorateurs. Les avantages deviennent évidents dans les projets plus importants :
- Routage basé sur les décorateurs :
@SubscribeMessage()mappe les événements aux gestionnaires, tout comme@Get()mappe les routes HTTP - Injection de dépendances : Les services, guards et pipes fonctionnent de manière identique aux contrôleurs HTTP
- Hooks de cycle de vie : Interfaces intégrées pour la gestion des connexions/déconnexions
- Organisation en modules : Séparation des fonctionnalités WebSocket en modules ciblés
- Guards et pipes : Réutilisation de la logique d'authentification et de validation existante
Vue d'Ensemble de l'Architecture
NestJS WebSocket Architecture
+-----------------------------------------------------------+
| NestJS Application |
| |
| +-------------------------------------------------------+ |
| | WebSocketModule | |
| | | |
| | +-----------+ +-----------+ +-----------+ | |
| | | ChatGW | | GameGW | | Notif.GW | | |
| | | /chat | | /game | | /notif | | |
| | +-----+-----+ +-----+-----+ +-----+-----+ | |
| | | | | | |
| | +---------------------------------------------------+ | |
| | | Services Partagés | | |
| | | +-------------+ +----------------+ | | |
| | | |ConnectedUser| | MessageService | | | |
| | | +-------------+ +----------------+ | | |
| | +---------------------------------------------------+ | |
| | | |
| | +---------------------------------------------------+ | |
| | | Préoccupations Transversales | | |
| | | +--------+ +--------+ +------------+ | | |
| | | | WsGuar | | WsPipe | | WsIntrcept | | | |
| | | +--------+ +--------+ +------------+ | | |
| | +---------------------------------------------------+ | |
| +-------------------------------------------------------+ |
| | |
| Socket.io Server |
| | |
+-----------------------------------------------------------+
|
+------------+---------------+
| | |
+----+-----+ +---+------+ +------+------+
| Client 1 | | Client 2 | | Client N |
| (Naviga.)| | (Mobile) | | (Bureau) |
+----------+ +----------+ +-------------+
Installation
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
Décorateurs Principaux
| Décorateur | Rôle |
|---|---|
| @WebSocketGateway() | Marque la classe comme gateway WebSocket (port, namespace, cors) |
| @WebSocketServer() | Injecte l'instance du serveur Socket.io |
| @SubscribeMessage() | Écoute des événements clients spécifiques |
| @MessageBody() | Extrait la charge utile du message depuis l'événement |
| @ConnectedSocket() | Injecte l'instance du socket client |
Gateway de Base
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
@WebSocketGateway({
namespace: 'chat',
cors: { origin: process.env.CLIENT_URL },
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private logger = new Logger('ChatGateway');
// Cycle de vie : serveur initialisé
afterInit(server: Server) {
this.logger.log('WebSocket server initialized');
}
// Cycle de vie : client connecté
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
// Cycle de vie : client déconnecté
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: { text: string; roomId: string },
@ConnectedSocket() client: Socket,
) {
// Diffusion dans la salle
this.server.to(data.roomId).emit('message', {
userId: client.id,
text: data.text,
timestamp: Date.now(),
});
// Retourner l'accusé de réception
return { success: true };
}
}
Hooks de Cycle de Vie
| Interface | Méthode | Déclenchement |
|---|---|---|
| OnGatewayInit | afterInit(server) | Après l'initialisation de la gateway |
| OnGatewayConnection | handleConnection(client) | Lors de la connexion d'un client |
| OnGatewayDisconnect | handleDisconnect(client) | Lors de la déconnexion d'un client |
Pipes de Validation avec DTOs
Les pipes NestJS fonctionnent avec les gateways WebSocket exactement comme avec les contrôleurs HTTP. Utilisez class-validator et class-transformer pour valider les messages entrants :
npm install class-validator class-transformer
import { IsString, IsNotEmpty, MaxLength, IsOptional } from 'class-validator';
// Définir un DTO pour les messages de chat
export class CreateMessageDto {
@IsString()
@IsNotEmpty()
@MaxLength(2000)
text: string;
@IsString()
@IsNotEmpty()
roomId: string;
@IsOptional()
@IsString()
replyToId?: string;
}
// Définir un DTO pour les opérations sur les salles
export class JoinRoomDto {
@IsString()
@IsNotEmpty()
roomId: string;
}
Appliquez le ValidationPipe globalement ou par gestionnaire pour valider automatiquement les charges utiles WebSocket entrantes :
import { UsePipes, ValidationPipe } from '@nestjs/common';
@WebSocketGateway({ namespace: 'chat' })
@UsePipes(new ValidationPipe({
transform: true, // Transformation automatique en instances DTO
whitelist: true, // Suppression des propriétés inconnues
forbidNonWhitelisted: true, // Erreur sur les propriétés inconnues
}))
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: CreateMessageDto, // Validé automatiquement
@ConnectedSocket() client: Socket,
) {
// data.text est garanti comme une chaîne non vide, max 2000 caractères
// data.roomId est garanti comme une chaîne non vide
this.server.to(data.roomId).emit('message', {
userId: client.userId,
text: data.text,
timestamp: Date.now(),
});
return { success: true };
}
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody() data: JoinRoomDto,
@ConnectedSocket() client: Socket,
) {
client.join(data.roomId);
return { success: true, roomId: data.roomId };
}
}
Guard d'Authentification
Protégez les connexions WebSocket avec l'authentification JWT en utilisant des guards. Le guard s'exécute avant tout gestionnaire de messages, tout comme les guards HTTP :
import { CanActivate, ExecutionContext, Injectable, UseGuards } from '@nestjs/common';
import { WsException, WebSocketGateway, SubscribeMessage, ConnectedSocket } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class WsJwtGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client = context.switchToWs().getClient();
const token = client.handshake.auth.token;
if (!token) {
throw new WsException('Missing authentication token');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
client.userId = decoded.userId;
return true;
} catch (err) {
throw new WsException('Invalid token');
}
}
}
// Appliquer le guard à la gateway
@WebSocketGateway()
@UseGuards(WsJwtGuard)
export class SecureGateway {
@SubscribeMessage('secure:action')
handleSecureAction(@ConnectedSocket() client: Socket) {
// client.userId est disponible depuis le guard
return { userId: client.userId };
}
}
Intercepteurs pour la Journalisation et les Métriques
Les intercepteurs s'enroulent autour de l'exécution des méthodes, parfaits pour la journalisation, le suivi des performances et la transformation des réponses :
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
UseInterceptors,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class WsLoggingInterceptor implements NestInterceptor {
private logger = new Logger('WebSocket');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
const handler = context.getHandler().name;
const now = Date.now();
this.logger.log(
`[IN] ${handler} from ${client.id} | payload: ${JSON.stringify(data)}`
);
return next.handle().pipe(
tap((response) => {
const duration = Date.now() - now;
this.logger.log(
`[OUT] ${handler} to ${client.id} | ${duration}ms | response: ${JSON.stringify(response)}`
);
}),
);
}
}
// Appliquer à une gateway
@WebSocketGateway({ namespace: 'chat' })
@UseInterceptors(WsLoggingInterceptor)
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any) {
// L'intercepteur journalise :
// [IN] handleMessage from abc123 | payload: {"text":"Hello"}
// [OUT] handleMessage to abc123 | 3ms | response: {"success":true}
return { success: true };
}
}
Patron des Namespaces Multiples
Séparez les préoccupations en utilisant différentes gateways pour différentes fonctionnalités. Chaque namespace fonctionne de manière indépendante avec ses propres middlewares, guards et gestionnaires d'événements :
// ─── Namespace Chat ──────────────────────────────────────
@WebSocketGateway({ namespace: 'chat' })
@UseGuards(WsJwtGuard)
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
constructor(
private connectedClients: ConnectedClientsService,
private messageService: MessageService,
) {}
handleConnection(client: Socket) {
this.connectedClients.addClient(client.id, client.userId);
}
handleDisconnect(client: Socket) {
this.connectedClients.removeClient(client.id);
}
@SubscribeMessage('message')
async handleMessage(@MessageBody() data: CreateMessageDto, @ConnectedSocket() client: Socket) {
const saved = await this.messageService.save({
text: data.text,
roomId: data.roomId,
userId: client.userId,
});
this.server.to(data.roomId).emit('message', saved);
return { success: true, messageId: saved.id };
}
@SubscribeMessage('typing')
handleTyping(@MessageBody() data: { roomId: string }, @ConnectedSocket() client: Socket) {
client.to(data.roomId).emit('typing', { userId: client.userId });
}
}
// ─── Namespace Jeu ──────────────────────────────────────
@WebSocketGateway({ namespace: 'game' })
@UseGuards(WsJwtGuard)
export class GameGateway {
@WebSocketServer() server: Server;
constructor(private gameService: GameService) {}
@SubscribeMessage('move')
async handleMove(
@MessageBody() data: { gameId: string; position: { x: number; y: number } },
@ConnectedSocket() client: Socket,
) {
const result = await this.gameService.processMove(data.gameId, client.userId, data.position);
// Diffusion de l'état de jeu mis à jour à tous les joueurs de la salle
this.server.to(`game:${data.gameId}`).emit('gameState', result);
return { success: true };
}
@SubscribeMessage('joinGame')
handleJoinGame(@MessageBody() data: { gameId: string }, @ConnectedSocket() client: Socket) {
client.join(`game:${data.gameId}`);
this.server.to(`game:${data.gameId}`).emit('playerJoined', { userId: client.userId });
}
}
// ─── Namespace Notifications ─────────────────────────────
@WebSocketGateway({ namespace: 'notifications' })
@UseGuards(WsJwtGuard)
export class NotificationsGateway {
@WebSocketServer() server: Server;
constructor(private connectedClients: ConnectedClientsService) {}
// Appelé par d'autres services via injection de dépendances
sendNotification(userId: string, notification: any) {
this.connectedClients.sendToUser(this.server, userId, 'notification', notification);
}
@SubscribeMessage('markRead')
handleMarkRead(@MessageBody() data: { notificationId: string }) {
// Marquer la notification comme lue en base de données
return { success: true };
}
}
Service de Clients Connectés
Suivez les utilisateurs connectés à travers les namespaces pour la messagerie ciblée et la détection de présence :
@Injectable()
export class ConnectedClientsService {
// Map socketId -> userId
private clients = new Map<string, string>();
addClient(socketId: string, userId: string) {
this.clients.set(socketId, userId);
}
removeClient(socketId: string) {
this.clients.delete(socketId);
}
getSocketIdByUserId(userId: string): string | undefined {
for (const [socketId, id] of this.clients) {
if (id === userId) return socketId;
}
return undefined;
}
// Obtenir tous les IDs d'utilisateurs connectés
getConnectedUserIds(): string[] {
return [...new Set(this.clients.values())];
}
// Vérifier si un utilisateur est en ligne
isUserOnline(userId: string): boolean {
return [...this.clients.values()].includes(userId);
}
// Envoyer à un utilisateur spécifique
sendToUser(server: Server, userId: string, event: string, data: any) {
const socketId = this.getSocketIdByUserId(userId);
if (socketId) {
server.to(socketId).emit(event, data);
}
}
// Envoyer à plusieurs utilisateurs
sendToUsers(server: Server, userIds: string[], event: string, data: any) {
for (const userId of userIds) {
this.sendToUser(server, userId, event, data);
}
}
}
Configuration du Module Gateway
Enregistrez les gateways et services dans un module. Chaque gateway est un provider, et les services partagés sont exportés pour être utilisés par d'autres modules :
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { GameGateway } from './game.gateway';
import { NotificationsGateway } from './notifications.gateway';
import { ConnectedClientsService } from './connected-clients.service';
@Module({
providers: [
ChatGateway,
GameGateway,
NotificationsGateway,
ConnectedClientsService,
],
exports: [ConnectedClientsService, NotificationsGateway],
})
export class WebSocketModule {}
Gestion des Exceptions
Gérez les exceptions WebSocket avec des filtres personnalisés. Contrairement aux exceptions HTTP, les erreurs WebSocket sont renvoyées au socket client :
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
@Catch(WsException)
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: WsException, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
const error = exception.getError();
client.emit('error', {
status: 'error',
message: error,
timestamp: new Date().toISOString(),
});
}
}
// Capturer TOUTES les exceptions (pas seulement WsException)
@Catch()
export class WsAllExceptionsFilter extends BaseWsExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
const message = exception instanceof Error
? exception.message
: 'Internal server error';
client.emit('error', {
status: 'error',
message,
timestamp: new Date().toISOString(),
});
}
}
// Appliquer à la gateway
@WebSocketGateway()
@UseFilters(new WsExceptionFilter())
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any) {
if (!data.text) {
throw new WsException('Message text is required');
}
// Traitement du message
}
}
Tester les Gateways WebSocket
Les gateways NestJS peuvent être testées avec le module standard @nestjs/testing. Testez à la fois la logique de la gateway et l'intégration Socket.io :
Tests Unitaires
import { Test, TestingModule } from '@nestjs/testing';
import { ChatGateway } from './chat.gateway';
import { ConnectedClientsService } from './connected-clients.service';
describe('ChatGateway', () => {
let gateway: ChatGateway;
let connectedClients: ConnectedClientsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ChatGateway,
ConnectedClientsService,
],
}).compile();
gateway = module.get<ChatGateway>(ChatGateway);
connectedClients = module.get<ConnectedClientsService>(ConnectedClientsService);
});
it('should be defined', () => {
expect(gateway).toBeDefined();
});
it('should handle client connection', () => {
const mockClient = { id: 'socket-1', userId: 'user-1' } as any;
gateway.handleConnection(mockClient);
expect(connectedClients.isUserOnline('user-1')).toBe(true);
});
it('should handle client disconnection', () => {
const mockClient = { id: 'socket-1', userId: 'user-1' } as any;
gateway.handleConnection(mockClient);
gateway.handleDisconnect(mockClient);
expect(connectedClients.isUserOnline('user-1')).toBe(false);
});
it('should broadcast message to room', () => {
const mockEmit = jest.fn();
const mockTo = jest.fn(() => ({ emit: mockEmit }));
gateway.server = { to: mockTo } as any;
const data = { text: 'Hello', roomId: 'room-1' };
const client = { id: 'socket-1', userId: 'user-1' } as any;
const result = gateway.handleMessage(data as any, client);
expect(mockTo).toHaveBeenCalledWith('room-1');
expect(mockEmit).toHaveBeenCalledWith('message', expect.objectContaining({
text: 'Hello',
userId: 'user-1',
}));
expect(result).toEqual({ success: true, messageId: expect.any(String) });
});
});
Tests d'Intégration avec le Client Socket.io
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { io, Socket as ClientSocket } from 'socket.io-client';
import { AppModule } from '../app.module';
describe('ChatGateway (Integration)', () => {
let app: INestApplication;
let clientSocket: ClientSocket;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.listen(3001);
});
beforeEach((done) => {
clientSocket = io('http://localhost:3001/chat', {
auth: { token: 'test-jwt-token' },
});
clientSocket.on('connect', done);
});
afterEach(() => {
clientSocket.close();
});
afterAll(async () => {
await app.close();
});
it('should receive messages', (done) => {
clientSocket.on('message', (data) => {
expect(data.text).toBe('Hello World');
done();
});
clientSocket.emit('message', {
text: 'Hello World',
roomId: 'test-room',
});
});
it('should reject invalid messages', (done) => {
clientSocket.on('error', (error) => {
expect(error.message).toContain('text');
done();
});
// Envoyer un message sans le champ requis 'text'
clientSocket.emit('message', { roomId: 'test-room' });
});
});
Mise à l'Échelle avec l'Adaptateur Redis
Par défaut, Socket.io ne fonctionne que sur une seule instance de serveur. Pour la mise à l'échelle horizontale sur plusieurs serveurs, utilisez l'adaptateur Redis pour synchroniser les événements :
npm install @socket.io/redis-adapter redis
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis(): Promise<void> {
const pubClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
// main.ts - Appliquer l'adaptateur
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(3000);
}
Mise à l'Échelle Horizontale avec l'Adaptateur Redis
+--------------+ +--------------+ +--------------+
| Serveur 1 | | Serveur 2 | | Serveur N |
| (NestJS) | | (NestJS) | | (NestJS) |
| Port 3000 | | Port 3000 | | Port 3000 |
+------+-------+ +------+-------+ +------+-------+
| | |
+---------------------+--------------------+
|
+------+------+
| Redis |
| Pub/Sub |
+-------------+
Les événements de n'importe quel serveur sont publiés dans Redis
et distribués aux clients sur TOUS les serveurs.
Conclusion
Les Gateways WebSocket NestJS apportent toute la puissance de l'écosystème NestJS aux applications temps réel. En tirant parti des décorateurs, de l'injection de dépendances et du système de modules, vous bénéficiez de la même expérience de développement structurée que les contrôleurs HTTP.
Points clés à retenir :
- Routage basé sur les décorateurs : Mappez les événements WebSocket aux gestionnaires avec
@SubscribeMessage() - Support DI complet : Injectez des services, guards, pipes et intercepteurs exactement comme les contrôleurs HTTP
- Validation : Utilisez des DTOs
class-validatoravecValidationPipepour une gestion des messages type-safe - Authentification : Protégez les gateways avec des guards JWT qui s'exécutent avant les gestionnaires de messages
- Testabilité : Testez les gateways en unitaire avec des mocks et en intégration avec de vrais clients Socket.io
- Évolutivité : Utilisez l'adaptateur Redis pour mettre à l'échelle sur plusieurs instances de serveur
Explorer le Code
Consultez le dépôt ft_transcendence sur GitHub pour voir ces patterns dans un vrai jeu multijoueur.
Écrit par

Tech Lead et Ingénieur Full Stack pilotant une équipe de 5 ingénieurs chez Fygurs (Paris, Remote) sur un SaaS cloud-native Azure. Diplômé de 1337 Coding School (42 Network / UM6P). Écrit sur l'architecture, l'infrastructure cloud et le leadership technique.