Skip to main content

Як реалізувати 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 залишається локальним для одного процесу.

Швидкий приклад

Спочатку встановлюємо пакети:

bash
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
typescript
import { 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:

typescript
// Неправильно - підключається до '/' namespace; жодні обробники не спрацьовують io('http://localhost:3000'); // Правильно - namespace має збігатися з @WebSocketGateway() io('http://localhost:3000/chat');

З'єднання виглядає успішним, але події не обробляються. Логи показують "connected" і більше нічого.

Broadcast без перевірки відправника:

typescript
// Неправильно - будь-хто може надіслати повідомлення всім клієнтам @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 призводить до витоку пам'яті:

typescript
// Неправильно - 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-хуками

typescript
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-аутентифікацією

typescript
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-адаптером

typescript
// 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'ами припиняється, але застосунок не крашиться.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?