Урок 04

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.