Socket.IO: комнаты и события
Комнаты
Комната — группа сокетов с общей подпиской.
Пример: чат по комнатам, документ с редакторами, игровая сессия.
join / leave
io.on('connection', (socket) => {
socket.on('room:join', ({ roomId }) => {
socket.join(`room:${roomId}`);
console.log(`${socket.id} в комнате ${roomId}`);
});
socket.on('room:leave', ({ roomId }) => {
socket.leave(`room:${roomId}`);
});
});
Что происходит: сокет добавляется в Set комнаты.
Отправка в комнату
Всем в комнате:
io.to('room:1').emit('message', 'Привет, комната 1');
Всем, кроме отправителя:
socket.to('room:1').emit('message', 'Кроме меня');
Нескольким комнатам:
io.to('room:1').to('room:2').emit('message', 'В обе комнаты');
Список комнат сокета:
socket.rooms; // Set: { socket.id, 'room:1', 'room:2' }
Кто в комнате
const sockets = await io.in('room:1').fetchSockets();
console.log('В комнате:', sockets.length);
sockets.forEach((s) => {
console.log(' ', s.id, s.data.user?.name);
});
fetchSockets() — асинхронный, потому что в масштабе — запрос в Redis.
Подсчёт
const size = io.sockets.adapter.rooms.get('room:1')?.size ?? 0;
Через adapter — потому что комнаты хранятся там.
Комнаты по умолчанию
Каждый сокет автоматически в комнате своего id:
socket.join(socket.id); // уже сделано
// Отправить конкретному сокету:
io.to(socket.id).emit('personal', 'Привет');
Полезно для персональных сообщений.
Комнаты для конкретного юзера
Комната на юзера:
io.use((socket, next) => {
// авторизация
socket.data.userId = user.id;
next();
});
io.on('connection', (socket) => {
socket.join(`user:${socket.data.userId}`);
});
// Отправить конкретному юзеру (во все его вкладки)
io.to(`user:${userId}`).emit('notification', 'Привет');
Что даёт: если у юзера 5 вкладок — все получат.
События — нейминг
Соглашение: namespace:action.
Примеры:
chat:message
chat:typing
chat:join
room:create
room:leave
user:online
user:offline
notification:new
Правило: префикс сущности, действие.
Payload
Всегда объект:
// Плохо
socket.emit('message', 'Привет');
// Хорошо
socket.emit('message', { text: 'Привет' });
Почему: легко добавить поля, читается.
Ack с ошибкой
socket.on('post:create', async ({ title }, callback) => {
if (!title) {
return callback({ error: 'title обязателен', code: 'VALIDATION' });
}
try {
const post = await Post.create({ title });
callback({ ok: true, post });
} catch (e) {
callback({ error: e.message, code: 'SERVER' });
}
});
Соглашение ответа:
{ ok: true, data }— успех.{ error, code }— ошибка.
Валидация
Используй Zod:
import { z } from 'zod';
const messageSchema = z.object({
text: z.string().min(1).max(1000),
roomId: z.string(),
});
socket.on('chat:message', ({ data, callback }) => {
const result = messageSchema.safeParse(data);
if (!result.success) {
return callback({ error: result.error.flatten(), code: 'VALIDATION' });
}
// ...
});
Временные комнаты
При отключении — выйти:
io.on('connection', (socket) => {
socket.on('disconnect', () => {
socket.rooms.forEach((room) => {
if (room !== socket.id) {
socket.leave(room);
}
});
});
});
Или — Socket.IO делает это автоматически при disconnect.
Комнаты + БД
Создание комнаты:
socket.on('room:create', async ({ name }, callback) => {
const room = await Room.create({ name, ownerId: socket.data.userId });
socket.join(`room:${room.id}`);
callback({ ok: true, room });
});
Присоединение:
socket.on('room:join', async ({ roomId }, callback) => {
const room = await Room.findByPk(roomId);
if (!room) {
return callback({ error: 'Комната не найдена', code: 'NOT_FOUND' });
}
socket.join(`room:${roomId}`);
io.to(`room:${roomId}`).emit('room:user:joined', {
userId: socket.data.userId,
socketId: socket.id,
});
callback({ ok: true });
});
Несколько сокетов одного юзера
socket.join(`user:${socket.data.userId}`);
// При отключении — проверить, есть ли другие сокеты этого юзера
socket.on('disconnect', async () => {
const sockets = await io.in(`user:${socket.data.userId}`).fetchSockets();
if (sockets.length === 0) {
io.emit('user:offline', { userId: socket.data.userId });
}
});
Что даёт: статус online только при полном отключении юзера.
Ограничения
Максимум комнат на сокет: не ограничено, но не злоупотребляй.
Слишком много комнат — нагрузка на adapter.
Практика: чат по комнатам
Сервер:
io.on('connection', (socket) => {
socket.on('room:join', async ({ roomId }, cb) => {
const room = await Room.findByPk(roomId);
if (!room) return cb({ error: 'Не найдена' });
socket.join(`room:${roomId}`);
io.to(`room:${roomId}`).emit('room:member:joined', {
userId: socket.data.userId,
});
cb({ ok: true });
});
socket.on('room:leave', ({ roomId }, cb) => {
socket.leave(`room:${roomId}`);
io.to(`room:${roomId}`).emit('room:member:left', {
userId: socket.data.userId,
});
cb({ ok: true });
});
socket.on('room:message', async ({ roomId, text }, cb) => {
if (!text?.trim()) return cb({ error: 'Пустое' });
const message = await Message.create({
roomId,
userId: socket.data.userId,
text,
});
io.to(`room:${roomId}`).emit('room:message', {
id: message.id,
userId: message.userId,
text: message.text,
createdAt: message.createdAt,
});
cb({ ok: true, id: message.id });
});
});
Клиент:
socket.emit('room:join', { roomId: 1 }, (res) => {
if (res.error) return console.error(res.error);
console.log('В комнате');
});
socket.on('room:message', (msg) => {
console.log(`[${msg.userId}] ${msg.text}`);
});
socket.emit('room:message', { roomId: 1, text: 'Привет' }, (res) => {
if (res.error) console.error(res.error);
});
Частые ошибки
1. Забыли socket.join:
socket.to('room').emit('x'); // никто не получит
// Правильно:
socket.join('room');
socket.to('room').emit('x');
2. Не вышли из комнаты:
Клиент остаётся в комнате после disconnect
Решение: Socket.IO удаляет автоматически.
3. io.emit вместо io.to(room).emit:
io.emit('x'); // ВСЕМ, включая тех, кто не в комнате
// Правильно:
io.to('room').emit('x');
4. Комнаты по имени юзера без префикса:
socket.join(userId); // конфликт с socket.id
// Правильно:
socket.join(`user:${userId}`);
5. Забыли fetchSockets async:
const sockets = io.in('room').fetchSockets(); // Promise, не массив
// Правильно:
const sockets = await io.in('room').fetchSockets();
Итог
- Комнаты — группы сокетов.
socket.join,socket.leave— вход/выход.io.to(room).emit— отправить в комнату.socket.to(room).emit— кроме отправителя.fetchSockets()— список.user:${userId}— комната пользователя (для мультивкладок).namespace:action— нейминг событий.- Ack —
{ ok }/{ error, code }.
Дальше — аутентификация в WebSocket.