Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати WebSocket Gateways у NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**WebSocket Gateway у NestJS** - це клас з `@WebSocketGateway()`, що обробляє Socket.IO-події в реальному часі на конкретному namespace. Реєструється як provider, підписується на події через `@SubscribeMessage()`, надсилає через `@WebSocketServer()`. ```typescript @WebSocketGateway({ namespace: '/chat' }) export class ChatGateway { @WebSocketServer() server: Server; @SubscribeMessage('message') handle(@MessageBody() data: string) { this.server.emit('message', data); // усім клієнтам } } ``` **Ключове:** gateway ділить HTTP-порт з контролерами, але управляє постійними з'єднаннями незалежно від них.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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'ами припиняється, але застосунок не крашиться.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.