Пошук уроків, статей та іншого контенту
Побудуєте real-time функціональність із кімнатами, трансляцією подій та масштабуванням.
Real-time застосунок підтримує постійне з’єднання між клієнтом і сервером. Сервер може надсилати події клієнту одразу після виникнення, не чекаючи нового HTTP-запиту.
У NestJS для цього зазвичай використовують WebSocket Gateway на базі Socket.IO:
Gateway приймає та надсилає WebSocket-події.
Кімната (room) групує з’єднання за певною ознакою.
Трансляція (broadcast) надсилає подію кільком клієнтам.
Adapter визначає, як сервер передає події між WebSocket-з’єднаннями.
Redis adapter дає змогу синхронізувати кілька екземплярів застосунку.
У цьому уроці побудуємо сервер для кімнат чату:
клієнт під’єднується до Gateway;
входить у кімнату;
надсилає повідомлення;
усі учасники кімнати отримують подію;
повідомлення також працює, коли застосунок запущено на кількох екземплярах.
Встановимо пакети:
npm install @nestjs/websockets @nestjs/platform-socket.io socket.ioСтворимо Gateway:
// src/chat/chat.gateway.ts
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
interface JoinRoomPayload {
roomId: string;
}
interface SendMessagePayload {
roomId: string;
text: string;
}
@WebSocketGateway({
namespace: '/chat',
cors: {
origin: 'http://localhost:3001',
},
})
export class ChatGateway {
@WebSocketServer()
private server: Namespace;
@SubscribeMessage('room:join')
async joinRoom(
@ConnectedSocket() socket: Socket,
@MessageBody() payload: JoinRoomPayload,
) {
const roomId = this.normalizeRoomId(payload.roomId);
await socket.join(roomId);
socket.emit('room:joined', {
roomId,
});
socket.to(roomId).emit('room:user-joined', {
roomId,
userId: socket.id,
});
}
@SubscribeMessage('room:leave')
async leaveRoom(
@ConnectedSocket() socket: Socket,
@MessageBody() payload: JoinRoomPayload,
) {
const roomId = this.normalizeRoomId(payload.roomId);
await socket.leave(roomId);
socket.to(roomId).emit('room:user-left', {
roomId,
userId: socket.id,
});
socket.emit('room:left', {
roomId,
});
}
@SubscribeMessage('message:send')
sendMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() payload: SendMessagePayload,
) {
const roomId = this.normalizeRoomId(payload.roomId);
const text = payload.text?.trim();
if (!text) {
socket.emit('message:error', {
code: 'EMPTY_MESSAGE',
message: 'Повідомлення не може бути порожнім',
});
return;
}
const message = {
id: crypto.randomUUID(),
roomId,
text,
authorId: socket.id,
createdAt: new Date().toISOString(),
};
// Подія надсилається всім учасникам кімнати, включно з автором.
this.server.to(roomId).emit('message:created', message);
}
handleConnection(socket: Socket) {
console.log(`Клієнт підключився: ${socket.id}`);
}
handleDisconnect(socket: Socket) {
console.log(`Клієнт відключився: ${socket.id}`);
}
private normalizeRoomId(roomId: string): string {
const normalizedRoomId = roomId?.trim();
if (!normalizedRoomId || normalizedRoomId.length > 100) {
throw new Error('Некоректний ідентифікатор кімнати');
}
return `room:${normalizedRoomId}`;
}
}У прикладі використано crypto.randomUUID(). Для Node.js, де ця функція доступна глобально, додатковий імпорт не потрібен.
Підключимо Gateway у модулі:
// src/chat/chat.module.ts
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
@Module({
providers: [ChatGateway],
})
export class ChatModule {}// src/app.module.ts
import { Module } from '@nestjs/common';
import { ChatModule } from './chat/chat.module';
@Module({
imports: [ChatModule],
})
export class AppModule {}Socket.IO дозволяє додавати сокет до кімнати методом join:
await socket.join('room:42');Після цього можна надсилати подію всім учасникам кімнати:
this.server.to('room:42').emit('message:created', {
text: 'Нове повідомлення',
});Кімната є логічним групуванням з’єднань. Вона не обов’язково відповідає кімнаті чату в базі даних. Наприклад, можна використовувати кімнати для:
конкретного чату;
сторінки документа;
групи користувачів;
спостереження за певним завданням;
оновлень конкретного проєкту.
Рекомендується додавати префікси до назв кімнат:
room:42
project:15
document:abc123Це зменшує ризик конфліктів між різними типами кімнат.
server.to і socket.toРозглянемо два варіанти:
this.server.to(roomId).emit('message:created', message);Подію отримають усі з’єднання в кімнаті, зокрема поточний клієнт.
socket.to(roomId).emit('room:user-joined', {
userId: socket.id,
});Подію отримають усі інші клієнти в кімнаті, але не клієнт, який виконав цю дію.
Це зручно для різних сценаріїв:
повідомлення чату зазвичай отримують усі учасники;
подія user:typing часто не повинна повертатися автору;
подія успішного приєднання може бути персональною для поточного клієнта.
Трансляція — це надсилання однієї події групі клієнтів.
this.server.emit('system:announcement', {
text: 'Сервер буде перезапущено',
});this.server.to(roomId).emit('document:updated', {
documentId,
version,
});socket.to(roomId).emit('user:typing', {
userId: socket.id,
});socket.emit('room:joined', {
roomId,
});Важливо розділяти:
події, які описують факт зміни стану;
події, які повідомляють про тимчасовий стан.
Наприклад:
message:created — постійна зміна, яку варто зберегти;
user:typing — тимчасова подія, яку не потрібно зберігати.
Gateway може реалізувати lifecycle-методи:
import {
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
export class ChatGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
handleConnection(socket: Socket) {
console.log(`Підключено ${socket.id}`);
}
handleDisconnect(socket: Socket) {
console.log(`Відключено ${socket.id}`);
}
}Після відключення Socket.IO автоматично видаляє сокет із його кімнат у межах конкретного сервера.
Проте застосунок не повинен покладатися лише на подію відключення для збереження бізнес-даних. Якщо користувач втратив мережу, з’єднання може бути тимчасово недоступним, а клієнт згодом підключиться з новим socket.id.
Клієнт Socket.IO може працювати з Gateway так:
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000/chat');
socket.on('connect', () => {
console.log('Підключено:', socket.id);
socket.emit('room:join', {
roomId: '42',
});
});
socket.on('room:joined', (event) => {
console.log('Приєднано до кімнати:', event.roomId);
});
socket.on('message:created', (message) => {
console.log('Нове повідомлення:', message);
});
socket.on('message:error', (error) => {
console.error(error.message);
});
function sendMessage(text) {
socket.emit('message:send', {
roomId: '42',
text,
});
}Назви подій є частиною контракту між клієнтом і сервером. Їх краще організовувати за схемою:
room:join
room:leave
message:send
message:created
message:errorТака схема робить призначення подій очевидним і спрощує підтримку клієнтського коду.
WebSocket-повідомлення також потрібно перевіряти. HTTP-пайпи не застосовуються до Gateway автоматично так само, як до контролерів, якщо не налаштована відповідна обробка.
Створимо DTO:
// src/chat/dto/send-message.dto.ts
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class SendMessageDto {
@IsUUID()
roomId: string;
@IsString()
@MinLength(1)
@MaxLength(2000)
text: string;
}Для кімнати, де ідентифікатор не є UUID, можна використати звичайний рядок із відповідними обмеженнями:
import { IsString, MaxLength, MinLength } from 'class-validator';
export class JoinRoomDto {
@IsString()
@MinLength(1)
@MaxLength(100)
roomId: string;
}У Gateway DTO передається як тип повідомлення:
@SubscribeMessage('message:send')
sendMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() payload: SendMessageDto,
) {
// Додаткова бізнес-перевірка виконується тут.
}Для складних застосунків варто централізовано налаштувати валідацію WebSocket-повідомлень і повертати клієнту подію з узгодженим форматом помилки.
Кімната не повинна бути єдиним механізмом контролю доступу. Клієнт може надіслати довільний roomId, тому перед join потрібно перевірити права користувача.
Логіка має виглядати так:
@SubscribeMessage('room:join')
async joinRoom(
@ConnectedSocket() socket: Socket,
@MessageBody() payload: JoinRoomDto,
) {
const roomId = this.normalizeRoomId(payload.roomId);
const userId = socket.data.userId;
const canJoin = await this.chatService.canJoinRoom(userId, roomId);
if (!canJoin) {
socket.emit('room:error', {
code: 'FORBIDDEN',
message: 'Немає доступу до кімнати',
});
return;
}
await socket.join(roomId);
socket.emit('room:joined', {
roomId,
});
}Користувача зазвичай визначають під час WebSocket-handshake, а результат зберігають у socket.data:
socket.data.userId = user.id;Перевірку прав потрібно виконувати не лише під час приєднання. Для чутливих операцій її слід повторювати під час обробки події.
Без додаткового adapter кожен процес NestJS знає лише про свої WebSocket-з’єднання.
Наприклад, є два процеси:
Клієнт A ──> NestJS instance 1
Клієнт B ──> NestJS instance 2Якщо клієнти перебувають в одній кімнаті, але підключені до різних процесів, виклик:
this.server.to(roomId).emit('message:created', message);без спільного adapter не доставить подію клієнту з іншого процесу.
Для синхронізації використаємо Redis adapter.
Встановимо залежності:
npm install redis @socket.io/redis-adapterRedis має бути доступним за адресою, наприклад:
redis://localhost:6379Створимо власний adapter:
// src/redis-io.adapter.ts
import { INestApplication } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { ServerOptions } from 'socket.io';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor?: ReturnType<typeof createAdapter>;
constructor(
private readonly app: INestApplication,
) {
super(app);
}
async connectToRedis(redisUrl: string): Promise<void> {
const pubClient = createClient({
url: redisUrl,
});
const subClient = pubClient.duplicate();
pubClient.on('error', (error) => {
console.error('Помилка Redis publisher:', error);
});
subClient.on('error', (error) => {
console.error('Помилка Redis subscriber:', error);
});
await Promise.all([
pubClient.connect(),
subClient.connect(),
]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: ServerOptions) {
const server = super.createIOServer(port, options);
if (!this.adapterConstructor) {
throw new Error('Redis adapter ще не підключено');
}
server.adapter(this.adapterConstructor);
return server;
}
}Підключимо adapter у точці запуску:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { RedisIoAdapter } from './redis-io.adapter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis(
process.env.REDIS_URL ?? 'redis://localhost:6379',
);
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();Тепер схема працює так:
Клієнт A ──> NestJS instance 1 ──┐
├──> Redis
Клієнт B ──> NestJS instance 2 ──┘Коли перший процес надсилає подію в кімнату, Redis adapter повідомляє інші процеси. Кожен процес доставляє подію локальним клієнтам, які є учасниками цієї кімнати.
Кожен екземпляр повинен мати:
однаковий код Gateway;
доступ до тієї самої Redis-служби;
однакову конфігурацію namespace;
доступний WebSocket-порт через балансувальник.
Наприклад, два процеси можуть використовувати різні порти:
PORT=3000 npm run start:prod
PORT=3001 npm run start:prodУ реальному середовищі балансувальник розподіляє нові підключення між процесами.
Socket.IO за замовчуванням може використовувати HTTP long-polling до встановлення WebSocket-з’єднання. Якщо наступний запит того самого клієнта потрапить на інший процес, це може спричинити помилку сесії.
Тому під час горизонтального масштабування потрібно виконати одну з умов:
налаштувати sticky sessions на балансувальнику;
дозволити лише WebSocket-транспорт і переконатися, що інфраструктура його підтримує.
Redis adapter синхронізує події, але сам по собі не замінює sticky sessions для polling-транспорту.
Кімнати Socket.IO є тимчасовими. Вони існують у пам’яті процесів і не замінюють таблиці в базі даних.
Якщо потрібні постійні дані, їх слід зберігати окремо:
інформацію про чат;
учасників;
повідомлення;
права доступу;
історію подій.
Типовий порядок обробки повідомлення:
перевірити автентифікацію;
перевірити доступ до кімнати;
зберегти повідомлення в базі даних;
сформувати подію;
транслювати подію учасникам кімнати.
const savedMessage = await this.messageRepository.create({
roomId,
authorId,
text,
});
this.server.to(roomId).emit('message:created', savedMessage);Якщо спочатку транслювати повідомлення, а потім зберігати його, клієнти можуть побачити подію, яка через помилку бази даних фактично не була збережена.
WebSocket-події не є автоматичною чергою повідомлень. Клієнт, який був відключений, може не отримати подію.
Тому для важливих даних потрібно передбачити повторне отримання стану:
клієнт підключається;
приєднується до кімнати;
запитує поточний стан або повідомлення після певного ідентифікатора;
отримує нові real-time події.
Real-time подія має повідомляти про зміну, але джерелом істини зазвичай залишається база даних.
Помилки не повинні призводити до неконтрольованого падіння Gateway. Для помилок бізнес-логіки використовуйте стабільний формат:
socket.emit('message:error', {
code: 'ROOM_NOT_FOUND',
message: 'Кімнату не знайдено',
});Коди помилок зручніші для клієнта, ніж аналіз тексту повідомлення:
socket.on('message:error', (error) => {
if (error.code === 'ROOM_NOT_FOUND') {
showRoomNotFoundMessage();
}
});Для технічних помилок не слід надсилати клієнту внутрішні деталі, наприклад SQL-запити або стек викликів.
roomId без перевірки правКлієнт може самостійно надіслати:
socket.emit('room:join', {
roomId: 'private-admin-room',
});Тому доступ до кімнати потрібно перевіряти на сервері.
Ці виклики мають різну поведінку:
this.server.emit('event', data);
this.server.to(roomId).emit('event', data);
socket.to(roomId).emit('event', data);
socket.emit('event', data);Потрібно явно визначити аудиторію кожної події.
Змінна в одному процесі не синхронізується з іншими процесами:
private onlineUsers = new Map<string, string>();Такий стан буде неповним після масштабування. Для спільного короткострокового стану можна використовувати Redis, а для постійних даних — базу даних.
Кімнати та події без adapter працюють лише в межах одного процесу. Після запуску другого екземпляра частина клієнтів перестане отримувати події.
Якщо подія надсилається до завершення операції в базі даних, клієнт може отримати неіснуюче повідомлення. Спочатку збережіть дані, потім транслюйте результат.
Клієнт може надсилати дуже великі або порожні значення. DTO та додаткова серверна перевірка повинні обмежувати розмір і формат payload.
disconnect дорівнює виходу користувачаВідключення може бути тимчасовим. socket.id також змінюється після нового підключення. Для статусу користувача потрібна окрема модель стану.
NestJS реалізує WebSocket-функціональність через Gateway.
socket.join(roomId) додає з’єднання до кімнати.
server.to(roomId).emit(...) надсилає подію всім учасникам кімнати.
socket.to(roomId).emit(...) виключає поточний клієнт із трансляції.
WebSocket-повідомлення потрібно валідувати та захищати перевіркою прав.
Socket.IO-кімнати є тимчасовими й не замінюють базу даних.
Для кількох екземплярів NestJS потрібен спільний adapter, наприклад Redis adapter.
Redis синхронізує події між процесами, але для polling-транспорту може знадобитися sticky session.
Важливі дані слід спочатку зберігати, а потім транслювати клієнтам.