Договор сообщений и учебная идентичность
Чат кажется простой формой: посетитель вводит текст, оператор отвечает. Однако между нажатием кнопки и появлением строки у другого человека находятся несколько независимых решений. Сервер должен понять, к какому обращению относится команда, кто её отправил, принято ли сообщение и каким образом восстановить переписку после разрыва. В этой серии рассмотрим каждое решение на одном проекте «Линия», постепенно добавляя механизм к уже знакомым данным.
Начнём с договора сообщений. Договором будем называть набор полей и правил, которые одинаково понимают браузер и сервер. Он не зависит от оформления страницы. Если позднее мы заменим форму или добавим уведомления, смысл команды останется прежним. Состояние stages/01 содержит только модель; сервер появится в следующем уроке. Код подготовлен для Node.js 24 LTS, но примеры серии не запускались: статус not_run, все описанные результаты ожидаемые.
Команда и событие
Команда выражает желание клиента изменить систему. Фраза «отправьте текст в обращение 17» ещё не означает, что текст записан. Событие описывает уже принятое сервером изменение: «в обращении 17 появилось сообщение». Если использовать один объект для обоих направлений, браузер легко начнёт выдавать желаемый результат за совершённый. Например, он покажет зелёную отметку сразу после передачи байтов, хотя сервер отклонил доступ к комнате.
Зафиксируем две комнаты: ticket-17 и ticket-18. Они представляют обращения поддержки, а не произвольные группы по интересам. В первой участвует вымышленная Алиса, во второй — вымышленный Борис. Оператор имеет доступ к обеим. Эти имена принадлежат учебным данным, поэтому в примерах не требуется заводить реальные аккаунты или копировать настоящую переписку. Идентификатор комнаты остаётся стабильным даже при изменении её видимого названия.
Полный файл contract.js задаёт начальную модель и образец команды. Он совпадает с одноимённым файлом снимка первого урока.
export const protocolVersion = 1;
export const roomIds = ['ticket-17', 'ticket-18'];
export const actors = {
alice: { id: 'alice', role: 'requester', rooms: ['ticket-17'] },
bob: { id: 'bob', role: 'requester', rooms: ['ticket-18'] },
agent: { id: 'agent', role: 'agent', rooms: roomIds }
};
export const sampleCommand = {
version: protocolVersion,
type: 'send',
roomId: 'ticket-17',
clientMessageId: 'demo-command-1',
text: 'Здравствуйте. Нужна помощь с импортом.'
};
Поле type сообщает серверу, какую операцию выполнить. roomId указывает её предметную область, а text содержит пользовательский ввод. clientMessageId создаётся для одной попытки отправить логическое сообщение и сохраняется при повторной передаче. Пока подавление дублей отсутствует, но сам идентификатор нужно предусмотреть заранее: иначе при добавлении подтверждений придётся менять договор сразу на двух сторонах.
У команды отсутствует actorId. Это намеренное решение. Если браузер мог бы сам объявить себя оператором внутри каждого сообщения, проверка серверной сессии не имела бы смысла. Сервер добавляет автора к событию из собственного контекста соединения. В нашем локальном примере контекст создаётся выбором вымышленного участника; в реальном сервисе он должен следовать из проверенного механизма входа и назначенных прав.
Идентичность учебного участника
Разделим выбор персонажа и проверку личности. Учебная страница позволит выбрать Алису, Бориса или оператора без пароля. Это удобно для понимания маршрутизации, но любой человек с доступом к локальной странице может выбрать любую роль. Поэтому такую кнопку нельзя подписывать «защищённый вход» и переносить на публичный сервер. В курсе она называется созданием учебной сессии и доступна только в приложении, привязанном к loopback-адресу.
В следующем состоянии сервер выдаст случайный идентификатор сессии в cookie HttpOnly. Браузер автоматически приложит cookie к запросу WebSocket на том же адресе. Мы не помещаем реальную личность, пароль или токен в параметры URL: адрес может попасть в журнал прокси, историю браузера и диагностический снимок. Даже случайную учебную cookie не нужно выводить рядом с именем участника или копировать в текст сообщения.
Свойство HttpOnly ограничивает доступ JavaScript страницы к cookie, но не превращает учебный выбор роли в настоящую аутентификацию. SameSite=Strict уменьшает нежелательную передачу cookie между сайтами, а проверка Origin ограничивает источники браузерного подключения. Каждая мера отвечает на свой вопрос. Ни одна сама по себе не доказывает, что пользователь является Алисой или сотрудником поддержки. Эта граница останется явной во всех состояниях проекта.
Версия и значение полей
Версия протокола позволяет отличать несовместимые формы сообщений. Значение 1 относится к описанному договору, а не к версии Node.js или пакета ws. Если новая операция требует другого обязательного поля, сервер должен либо поддержать старую форму отдельно, либо сообщить, что версия не принимается. Молчаливое толкование неизвестного объекта опаснее явного отказа: пользователь может считать сообщение отправленным, хотя сервер понял совсем другую команду.
Рассмотрим ожидаемое серверное событие после принятия образца. Идентификатор и время здесь условные; это схема результата, а не записанный вывод программы.
{
"version": 1,
"type": "message",
"roomId": "ticket-17",
"messageId": "server-generated-id",
"clientMessageId": "demo-command-1",
"actorId": "alice",
"text": "Здравствуйте. Нужна помощь с импортом."
}
Различие двух идентификаторов отражает различие обязанностей. Клиентский идентификатор связывает повтор и подтверждение с действием в форме. Серверный идентификатор обозначает принятую запись и остаётся одинаковым для всех участников комнаты. Время удобно для чтения, но не подходит для строгого порядка: часы могут расходиться, несколько записей могут получить одинаковое время, а после переподключения события иногда приходят повторно. Порядок позднее зададим отдельным номером внутри комнаты.
Попробуйте мысленно изменить только текст команды, сохранив clientMessageId. Получилось новое сообщение или конфликт старого действия? В «Линии» это конфликт: идентификатор относится к неизменному содержимому. Если пользователь действительно отправляет другую фразу, браузер создаёт новый идентификатор. Такое правило облегчает диагностику и позволяет вернуть ясную ошибку вместо незаметного появления двух разных сообщений с общей отметкой отправки.
Рассмотрите ещё различие между обращением и соединением. Участник может закрыть вкладку, а обращение остаётся предметным объектом. Он может открыть две вкладки для одного разговора, получив два канала одного автора. Поэтому идентификатор сокета не подходит в качестве имени комнаты или постоянной личности. Аналогично случайный messageId относится к записи, а не к роли оператора. Когда эти значения хранятся раздельно, последующее добавление истории не требует сохранять давно закрытый сетевой объект. Такая модель помогает объяснить и ошибку: неизвестная комната относится к ресурсу, закрытый сокет относится к транспорту, а отсутствующий автор относится к серверному контексту. Исправление одного состояния не должно незаметно менять остальные.
Основа серии — официальный WebSocket Protocol и браузерный WebSocket API. Они задают транспорт, но поля команды и смысл обращения принадлежат нашему приложению. Первый снимок можно читать без установки пакетов. Дальше перенесём этот договор в HTTP-каркас, сохранив комнаты, участников и разделение команды с событием.