Як реалізувати WebSocket Gateways у NestJS?
WebSocket Gateway у NestJS - це клас з декоратором @WebSocketGateway(), що створює Socket.IO-endpoint для двостороннього зв'язку в реальному часі. Живе окремо від HTTP-маршрутів, але на тому ж порту.
Теорія
TL;DR
- Gateway - як стійка адміністратора готелю для живих розмов: HTTP вирішує разові запити, а gateway підтримує постійний діалог без нового запиту кожного разу.
- Головна різниця від контролерів: gateway монтує Socket.IO-сервер на конкретний namespace; контролери працюють тільки з REST.
- HTTP і WebSocket живуть на одному порту через механізм заголовка Upgrade.
- Живі оновлення (чат, нотифікації, присутність) - gateway. Разові запити даних - HTTP-контролер.
- Для кількох серверних інстанцій у продакшені потрібен Redis-адаптер. Без нього broadcast залишається локальним для одного процесу.
Швидкий приклад
Спочатку встановлюємо пакети:
npm install @nestjs/websockets @nestjs/platform-socket.io socket.ioimport { WebSocketGateway, SubscribeMessage, MessageBody, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } })
export class ChatGateway {
@WebSocketServer() server: Server;
@SubscribeMessage('sendMessage')
handleMessage(@MessageBody() data: string): void {
this.server.emit('newMessage', data); // надсилає всім підключеним клієнтам
}
}
// Реєструємо як provider в app.module.ts, не як controller
@Module({ providers: [ChatGateway] })
export class AppModule {}Клієнт підключається через io('http://localhost:3000/chat') і надсилає socket.emit('sendMessage', 'Hello!'). Всі підключені клієнти отримують broadcast миттєво.
Ключова різниця від HTTP-контролерів
Контролери використовують Express/Fastify для stateless запит-відповідь. Gateway запускає Socket.IO-сервер через @WebSocketServer() і маршрутизує події через namespaces. Спільний http.Server отримує запит: якщо прийшов Upgrade-заголовок, Socket.IO перехоплює його і Engine.IO встановлює постійне TCP-з'єднання. Далі події потрапляють до обробників через metadata декораторів, а lifecycle-хуки на зразок handleConnection спрацьовують по socket.on('connect').
Коли використовувати
- Typing indicators, присутність користувача, позиції курсора - gateway з namespaces.
- Завантажити історію чату або профіль користувача - HTTP-контролер.
- Більше 10 подій на секунду на користувача - gateway з rooms.
- Stateless API без real-time вимог - REST, gateway не потрібен.
- Кілька серверних інстанцій у продакшені - gateway плюс Redis-адаптер.
Патерн аутентифікації
JWT передається в handshake.auth від клієнта. Перевіряй у handleConnection і зберігай результат у client.data. Для аутентифікації на рівні конкретного методу використовуй @UseGuards() з кастомним CanActivate, що читає дані через context.switchToWs().getClient(). Якщо перевірка не пройшла - відправ помилку і викликай client.disconnect() до виконання будь-якого обробника.
Масштабування з Redis-адаптером
Один процес NestJS тримає весь стан сокетів у пам'яті. Запусти дві інстанції - і this.server.to(room).emit() з одного pod'а дійде тільки до клієнтів, підключених до того ж pod'а. Redis-адаптер синхронізує broadcast між інстанціями через pub/sub.
Більшість команд натикається на це не під час локальної розробки, а при першому деплої на Kubernetes - після того Redis-адаптер стає обов'язковим.
Типові помилки
Підключення до неправильного namespace:
// Неправильно - підключається до '/' namespace; жодні обробники не спрацьовують
io('http://localhost:3000');
// Правильно - namespace має збігатися з @WebSocketGateway()
io('http://localhost:3000/chat');З'єднання виглядає успішним, але події не обробляються. Логи показують "connected" і більше нічого.
Broadcast без перевірки відправника:
// Неправильно - будь-хто може надіслати повідомлення всім клієнтам
@SubscribeMessage('msg')
handle(@MessageBody() data: any) {
this.server.emit('msg', data);
}
// Правильно - спочатку перевіряємо client.data.user
@SubscribeMessage('msg')
handle(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
if (!client.data.user) return;
this.server.emit('msg', { userId: client.data.user.id, ...data });
}Відсутній handleDisconnect призводить до витоку пам'яті:
// Неправильно - Map росте вічно; тисячі "привидів" після стрибка підключень
private users = new Map<string, UserInfo>();
handleConnection(client: Socket) { this.users.set(client.id, {}); }
// handleDisconnect відсутній
// Правильно
handleDisconnect(client: Socket) { this.users.delete(client.id); }cors: '*' у продакшені відкриває вразливість cross-site WebSocket hijacking (CSWSH). Використовуй { origin: ['https://myapp.com'], credentials: true } натомість.
Кілька інстанцій без Redis-адаптера: PM2-кластери і Kubernetes pod'и тримають кожен свій стан сокетів. Broadcast мовчки губиться для клієнтів на інших інстанціях.
Де використовується
- Чат-застосунки (архітектура Rocket.Chat): окремий namespace на фічу, rooms на тред розмови.
- Живі дашборди: тіки цін через namespaces, без polling.
- Нотифікації: черги
@nestjs/bullплюс emit з gateway після завершення фонового завдання. - Колаборативні інструменти: broadcast позицій курсора між підключеними клієнтами.
- Ігри: часті оновлення стану гравців з rooms на кожну ігрову сесію.
Питання на співбесіді
Q: Як NestJS обробляє WebSocket і HTTP на одному порту?
A: Socket.IO перехоплює HTTP Upgrade-заголовок до того, як запит потрапляє до Express/Fastify. Якщо handshake проходить, Engine.IO бере TCP-з'єднання і тримає його відкритим. Звичайні HTTP-запити йдуть через middleware-стек як завжди.
Q: У чому різниця між rooms і namespaces?
A: Namespaces ділять сам Socket.IO-сервер, як окремі логічні сервери на одному порту. Rooms групують клієнтів всередині namespace і підтримують динамічне приєднання і вихід, але не ізолюють обробники подій.
Q: Як надсилати події з сервісу, а не безпосередньо з gateway?
A: Inject gateway у сервіс і викликай gateway.server.to(room).emit(...). Або використовуй спільний event emitter, на який gateway підписується. Другий підхід дозволяє не тягнути залежність від gateway у не пов'язані сервіси.
Q: Що відбувається з кімнатами при перепідключенні клієнта?
A: Socket.IO не відновлює членство в кімнатах автоматично. Повторно додавай клієнта до кімнат у handleConnection, використовуючи збережений стан - Redis-хеш або запис у БД за user ID.
Q: Як реалізувати sticky sessions для балансувальника навантаження?
A: Налаштуй Nginx або HAProxy на маршрутизацію за хешем socket.id, щоб клієнт завжди потрапляв на одну інстанцію під час handshake. Socket.IO v4.7+ також підтримує sticky: true на сервері. Без sticky-маршрутизації HTTP-handshake і WebSocket Upgrade можуть потрапити на різні інстанції - з'єднання обривається. Redis-адаптер вирішує окрему проблему: синхронізацію broadcast, а не маршрутизацію handshake.
Приклади
Базовий gateway з lifecycle-хуками
import {
WebSocketGateway, WebSocketServer, SubscribeMessage,
MessageBody, ConnectedSocket, OnGatewayConnection, OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
handleConnection(client: Socket) {
console.log('Клієнт підключився:', client.id);
}
handleDisconnect(client: Socket) {
console.log('Клієнт відключився:', client.id);
}
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: { room: string; text: string },
@ConnectedSocket() client: Socket,
) {
this.server.to(data.room).emit('message', {
user: client.id,
text: data.text,
timestamp: new Date(),
});
}
@SubscribeMessage('joinRoom')
handleJoinRoom(@MessageBody() room: string, @ConnectedSocket() client: Socket) {
client.join(room);
this.server.to(room).emit('userJoined', { userId: client.id });
return { event: 'joinedRoom', data: room }; // підтвердження назад відправнику
}
}handleConnection і handleDisconnect спрацьовують автоматично. @SubscribeMessage('joinRoom') повертає об'єкт-підтвердження прямо клієнту, що надіслав запит.
Чат з rooms і JWT-аутентифікацією
import { UseGuards, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class WsAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client = context.switchToWs().getClient<Socket>();
return !!client.data.user;
}
}
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection {
constructor(private readonly authService: AuthService) {}
@WebSocketServer() server: Server;
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth.token;
const user = await this.authService.verifyToken(token);
client.data.user = user; // зберігаємо для наступних обробників
client.join('general'); // автоматично додаємо в дефолтну кімнату
} catch {
client.emit('error', { message: 'Аутентифікація не вдалася' });
client.disconnect(); // закриваємо до виконання будь-якого обробника
}
}
@SubscribeMessage('joinRoom')
@UseGuards(WsAuthGuard)
joinRoom(@MessageBody() { room }: { room: string }, @ConnectedSocket() client: Socket) {
client.join(room);
client.emit('joined', `Ласкаво просимо до ${room}`);
}
@SubscribeMessage('message')
sendToRoom(
@MessageBody() { room, msg }: { room: string; msg: string },
@ConnectedSocket() client: Socket,
) {
this.server.to(room).emit('message', { userId: client.id, msg });
}
}Перевірка JWT відбувається в handleConnection до запуску будь-якого обробника повідомлень. @UseGuards(WsAuthGuard) додає другий рівень захисту для чутливих методів. server.to(room).emit() надсилає тільки учасникам кімнати.
Горизонтальне масштабування з Redis-адаптером
// main.ts
import { NestFactory } from '@nestjs/core';
import { createAdapter } from '@socket.io/redis-adapter';
import { AppModule } from './app.module';
import Redis from 'ioredis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const pubClient = new Redis({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();
// синхронізуємо broadcast між усіма інстанціями через Redis pub/sub
app.useWebSocketAdapter(createAdapter(pubClient, subClient));
await app.listen(3000);
}
bootstrap();З цим налаштуванням this.server.to('room').emit() у будь-якому gateway дістається до клієнтів на всіх інстанціях. Якщо Redis падає, інстанції повертаються до in-memory режиму - broadcast між pod'ами припиняється, але застосунок не крашиться.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.