Перейти к содержанию

Чат на WebSocket: комнаты обращений

Оглавление серии

Общая рассылка подходит для демонстрации канала, но в поддержке каждое обращение имеет собственную переписку. Введём комнаты «Линии»: соединение сначала выбирает обращение командой join, а последующие сообщения направляются только сокетам, выбравшим ту же комнату. В результате два обращения могут одновременно обслуживаться одним сервером, сохраняя отдельные потоки сообщений.

В этом уроке изучаем маршрутизацию, а права на конкретное обращение добавим следующим состоянием. Такое промежуточное разделение допустимо только для вымышленных локальных данных. Используйте stages/06 и не переносите его на действующий сервис. Код имеет статус not_run: комнаты и ответы описаны как ожидаемые результаты, без запуска браузерного или серверного примера.

Комната относится к соединению

Свойство client.roomId хранит текущую подписку сокета. Команда join изменяет эту подписку и возвращает событие joined. Оно сообщает браузеру, что сервер принял выбранную комнату. Только после этого события форма получает состояние joined = true и возможность отправлять сообщение. Само изменение select не считается входом: интерфейс ждёт предметное подтверждение от сервера.

Один сокет в нашем проекте подписан на одну комнату. Это упрощает модель списка сообщений и порядок отправки. Большой клиент может поддерживать несколько подписок одновременно, однако тогда нужно отдельно решать, какой журнал виден, куда относится набранный текст и какие события обновляют только счётчики. Не стоит вводить множество комнат в одном соединении раньше, чем приложение научится ясно управлять одной активной комнатой.

Ниже полный socket.js шестого состояния. Новая ветвь join находится до обработки отправки, а при рассылке сравнивается серверная подписка получателя с комнатой команды.

import { WebSocket, WebSocketServer } from 'ws';
import { randomUUID } from 'node:crypto';
import { origin, readSession, canEnter } from './identity.js';
import { parseCommand } from './protocol.js';

function send(client, event) {
  if (client.readyState === WebSocket.OPEN) {
    client.send(JSON.stringify(event));
  }
}


export function attachSockets(server) {
  const wss = new WebSocketServer({ noServer: true, maxPayload: 8192 });
  server.on('upgrade', (request, socket, head) => {
    socket.on('error', () => socket.destroy());
    const path = new URL(request.url, origin).pathname;
    const session = readSession(request);
    if (path !== '/chat' || request.headers.origin !== origin || !session) {
      socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
      socket.destroy();
      return;
    }
    wss.handleUpgrade(request, socket, head, client => {
      client.actor = session.actor;
      client.expiresAt = session.expiresAt;
      wss.emit('connection', client);
    });
  });
  wss.on('connection', client => {
    client.on('error', () => client.terminate());
    send(client, { version: 1, type: 'ready', actorId: client.actor.id });


    client.on('message', (data, isBinary) => {
      try {
        if (Date.now() >= client.expiresAt) {
          client.close(1008, 'session_expired');
          return;
        }

        const command = parseCommand(data, isBinary);
        if (command.type === 'join') {
          client.roomId = command.roomId;
          send(client, { type: 'joined', roomId: client.roomId });
          return;
        }
        if (client.roomId !== command.roomId) throw new Error('join_required');
        if (command.type !== 'send') throw new Error('unsupported_command');

        const message = {
          version: 1,
          type: 'message',
          roomId: command.roomId,
          messageId: randomUUID(),
          clientMessageId: command.clientMessageId,
          actorId: client.actor.id,
          text: command.text,
          createdAt: new Date().toISOString()
        };

        for (const peer of wss.clients) {
          if (peer.roomId === command.roomId) send(peer, message);
        }

      } catch (error) {
        send(client, { version: 1, type: 'error', code: error.message });
      }
    });

  });
  return { wss, close() {
    for (const client of wss.clients) client.close(1001, 'server_stopping');
    wss.close();
  } };
}

Транспорт WebSocket сам не создаёт комнаты. Все соединения остаются обычными каналами, а поле и цикл рассылки принадлежат нашему коду. В библиотеке ws набор wss.clients содержит подключённых клиентов сервера; мы фильтруем его по прикладному признаку. Такое понимание позволяет позже заменить хранение подписок, не меняя смысл самой операции отправки.

Проверка client.roomId !== command.roomId предотвращает отправку в комнату, на которую этот сокет не подписан. Представьте, что интерфейс ещё показывает первую переписку, а команда по ошибке содержит вторую: сервер возвращает join_required, вместо того чтобы молча изменить другое обращение. Подписка не равна авторизации, но она связывает контекст видимого разговора и контекст изменения данных.

Смена обращения требует переходного состояния

Когда оператор выбирает другую комнату, клиент очищает видимый список и отправляет новую команду входа. На время ожидания joined кнопка выключена. Это защищает от обычной гонки интерфейса: пользователь успел выбрать второе обращение, но отправил фразу до того, как сервер сменил подписку. Без явного переходного состояния трудно объяснить, к какому разговору относилась такая отправка.

В текущем снимке история отсутствует, поэтому после входа список пуст. Мы не пытаемся восстановить старые сообщения по DOM, оставшемуся от другой комнаты. В следующих уроках сервер вернёт журнал, и интерфейс наполнит список из предметных данных. Очистка представления не удаляет сообщения на сервере; в шестом состоянии их там пока вообще не хранит долговечное или временное хранилище.

Рассмотрим ожидаемую последовательность в двух отдельных профилях браузера. Алиса выбирает ticket-17, оператор также выбирает ticket-17. Фраза Алисы должна появиться у обоих. Затем оператор переключается на ticket-18. Новая фраза Алисы остаётся в первой комнате и не появляется в текущем журнале оператора. Соединение оператора всё ещё открыто, но его прикладная подписка изменилась.

{"type":"joined","roomId":"ticket-17"}

Это событие следует читать буквально: «сокет подписан на комнату 17». Оно не означает, что оператор прочитал предыдущий текст, что в комнате присутствуют другие люди или что история сохранена. Отдельные события нужны не ради красивой схемы, а чтобы интерфейс не приписывал одной технической операции несколько неподтверждённых значений. Присутствие, история и подтверждения появятся со своими правилами.

Чего маршрутизация ещё не обеспечивает

В шестом снимке Алиса способна отправить join для второй комнаты, поскольку сервер пока проверяет только известное имя. Список комнат также содержит обе записи. Мы рассматриваем это как явную учебную границу, а не как скрытую уязвимость готового продукта. Реальная система поддержки должна проверять права сервером до подписки, до выдачи истории и до каждого изменения. Нельзя считать select достаточной защитой.

Если клиент закрыл соединение, он исчезнет из набора wss.clients. Поэтому дополнительные карты комнат в этом простом варианте не требуют ручной очистки. При отдельном реестре подписок пришлось бы удалять сокет на close, иначе сервер хранил бы ссылки на несуществующие соединения. Чем больше производных структур мы добавляем для ускорения рассылки, тем важнее становится согласование их жизненного цикла.

Цикл проходит по всем клиентам, поэтому стоимость одной рассылки зависит от общего количества соединений. Для двух учебных обращений это понятная реализация. При большом числе комнат обычно хранят набор сокетов на комнату, сокращая обход. Такая оптимизация не должна менять правило отбора и очистки. Сначала убедитесь в корректной маршрутизации, затем выбирайте структуру данных по измеренной нагрузке.

Если сервер работает в нескольких процессах, каждый набор клиентов видит только собственные соединения. Сообщение Алисы, попавшее в один процесс, не окажется автоматически у оператора в другом. Для этого потребуется общий журнал и канал координации между процессами. В учебном проекте сервер один; мы не выдаём локальный цикл рассылки за горизонтально масштабируемую систему и не предполагаем, что библиотека решает этот вопрос сама.

При ошибке команды сервер посылает событие error одному отправителю, а не всей комнате. Неверный ввод Алисы не относится к переписке оператора. Такой выбор удерживает диагностические события отдельно от содержательных сообщений. Позднее ошибки отправки будут связаны с конкретным clientMessageId, чтобы пользователь мог понять, какая фраза требует исправления или повтора, особенно при нескольких незавершённых действиях.

Механика набора клиентов и рассылки рассмотрена в README ws. После этого состояния у соединения есть предметная подписка, интерфейс ждёт joined, а событие отправляется только выбранной группе получателей. Следующий шаг превратит известную комнату в разрешённую комнату конкретного участника.