Состояния браузерного WebSocket
Сервер уже умеет переключать соединение на WebSocket и рассылать событие принятого сообщения. Теперь добавим браузерный клиент «Линии». Главная задача этого состояния — правильно связать форму со временем открытия канала. Создание объекта WebSocket ещё не означает, что можно отправлять данные: handshake проходит асинхронно, а состояние может измениться, пока пользователь вводит текст.
Используйте полное состояние stages/04, сохраняя его HTML и JavaScript вместе. Браузерный WebSocket встроен в платформу; пакет ws нужен серверу, а не тегу script. Пример подготовлен по документации, но не запускался: not_run. Все описания переходов ниже — ожидаемое поведение после самостоятельного воспроизведения в локальном окружении. Адрес остаётся http://127.0.0.1:4310.
Открытие и отправка не совпадают
У канала четыре состояния: он открывается, открыт, закрывается или уже закрыт. Событие open означает завершение handshake. Именно после него разрешаем отправку формы. Событие close снова выключает кнопку, даже если последняя видимая надпись всё ещё говорит об открытом соединении. Ошибка транспорта сама по себе не является серверным отказом команды; её следует показывать как состояние связи, а не как неправильный текст сообщения.
Клиент сначала создаёт учебную сессию через fetch и получает комнаты обычным HTTP-запросом. Только затем создаётся сокет. Такой порядок важен: cookie должна попасть в браузер до handshake. Если вызвать оба действия одновременно, сетевой порядок станет случайным и часть подключений получит отказ 401. Проблема будет выглядеть как нестабильность WebSocket, хотя её причина — зависимость между двумя запросами.
Полный файл public/client.js четвёртого снимка приведён ниже. Переменная stage обозначает возможности именно этого состояния; ветви будущих возможностей не включены значением 4. Полный снимок позволяет проследить дальнейшие изменения, не заменяя код многоточиями.
const stage = 4;
const $ = selector => document.querySelector(selector);
const pending = new Map();
const cursors = new Map();
let socket;
let generation = 0;
let attempt = 0;
let retryTimer;
let feed;
let activeRoom = 'ticket-17';
let joined = false;
function status(text) {
$('#status').textContent = text;
$('#send').disabled = !joined;
}
function showMessage(message, announce = true) {
const cursor = cursors.get(message.roomId) || { epoch: '', after: 0 };
if (stage >= 10 && cursor.epoch === message.epoch && message.seq <= cursor.after) {
return;
}
const item = document.createElement('li');
item.textContent = `${message.actorId}: ${message.text}`;
$('#messages').append(item);
if (message.seq) cursors.set(message.roomId, {
epoch: message.epoch, after: message.seq
});
if (stage >= 15 && announce) $('#announcement').textContent =
`Новое сообщение от ${message.actorId}`;
}
function join() {
joined = false;
status('Вход в обращение');
const cursor = cursors.get(activeRoom);
const command = stage >= 11 && cursor ?
{ type: 'resume', ...cursor } : { type: 'join' };
socket.send(JSON.stringify({ version: 1, roomId: activeRoom, ...command }));
}
function openSocket(token) {
joined = false;
status('Соединение открывается');
const current = new WebSocket('ws://127.0.0.1:4310/chat');
socket = current;
current.addEventListener('open', () => {
if (token !== generation) return;
attempt = 0;
if (stage >= 6) join();
else { joined = true; status('Соединение открыто'); }
});
current.addEventListener('message', event => {
if (token !== generation) return;
const value = JSON.parse(event.data);
if (value.type === 'history') {
if (value.reset) {
$('#messages').replaceChildren();
cursors.set(value.roomId, { epoch: value.epoch, after: 0 });
}
for (const message of value.messages) showMessage(message, false);
cursors.set(value.roomId, { epoch: value.epoch, after: value.through });
}
if (value.type === 'joined') {
joined = true;
status('Обращение открыто');
for (const command of pending.values()) {
if (command.roomId === activeRoom) current.send(JSON.stringify(command));
}
}
if (value.type === 'message') showMessage(value);
if (value.type === 'ack') {
pending.delete(value.clientMessageId);
$('#pending').textContent = `Ожидают подтверждения: ${pending.size}`;
}
if (value.type === 'presence') $('#presence').textContent =
`В обращении: ${value.actors.join(', ')}`;
if (value.type === 'error') status(`Команда отклонена: ${value.code}`);
});
current.addEventListener('error', () => {
if (token === generation) status('Ошибка соединения');
});
current.addEventListener('close', event => {
if (token !== generation) return;
joined = false;
status(`Соединение закрыто: ${event.code}`);
if (stage >= 11 && ![1000, 1008].includes(event.code)) {
const delay = Math.min(1000 * 2 ** attempt++, 10000) + Math.random() * 500;
retryTimer = setTimeout(() => openSocket(token), delay);
}
});
}
$('#session').addEventListener('submit', async event => {
event.preventDefault();
generation += 1;
const token = generation;
clearTimeout(retryTimer);
socket?.close(1000, 'new_demo_session');
feed?.close();
pending.clear();
cursors.clear();
$('#messages').replaceChildren();
joined = false;
status('Создание учебной сессии');
try {
const response = await fetch('/demo/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actorId: $('#actor').value })
});
if (!response.ok) throw new Error('session_failed');
const roomsResponse = await fetch('/api/rooms');
if (!roomsResponse.ok) throw new Error('rooms_failed');
const { rooms } = await roomsResponse.json();
if (token !== generation) return;
$('#room').replaceChildren(...rooms.map(roomId => {
const option = document.createElement('option');
option.value = roomId;
option.textContent = roomId;
return option;
}));
activeRoom = rooms[0];
$('#room').disabled = stage < 6;
openSocket(token);
if (stage >= 13) {
feed = new EventSource('/events');
feed.addEventListener('notification', () => {
$('#pending').textContent = 'Есть обновление обращения';
});
feed.addEventListener('resync', () => {
$('#pending').textContent = 'Список уведомлений нужно обновить';
});
}
} catch {
if (token === generation) status('Не удалось открыть учебную сессию');
}
});
$('#room').addEventListener('change', () => {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
activeRoom = $('#room').value;
$('#messages').replaceChildren();
cursors.delete(activeRoom);
join();
});
$('#composer').addEventListener('submit', event => {
event.preventDefault();
if (!joined || socket.readyState !== WebSocket.OPEN) return;
const text = $('#text').value.trim();
if (!text || text.length > 1000) return;
if (stage >= 8 && pending.size >= 20) {
status('Дождитесь подтверждения предыдущих сообщений');
return;
}
const command = {
version: 1, type: 'send', roomId: activeRoom,
clientMessageId: crypto.randomUUID(), text
};
if (stage >= 8) pending.set(command.clientMessageId, command);
socket.send(JSON.stringify(command));
$('#text').value = '';
$('#pending').textContent = `Ожидают подтверждения: ${pending.size}`;
$('#text').focus();
});
Счётчик generation различает последовательные попытки открыть учебную сессию. Представьте, что пользователь быстро нажал кнопку дважды: первый fetch завершится позже второго. Без проверки поколения старый ответ мог бы открыть сокет поверх новой сессии, вернуть прежний список комнат и изменить надпись в интерфейсе. Это обычная асинхронная гонка, которую нельзя устранить только выключением кнопки отправки.
Каждый обработчик сокета получает сохранённое значение поколения. Если оно уже не совпадает с текущим, событие игнорируется. Старый сокет может успеть вызвать close после открытия нового, но такой callback не выключит новый интерфейс. Сами соединения при смене сессии закрываются с кодом нормального завершения. Игнорирование устаревшего события и освобождение устаревшего ресурса дополняют друг друга.
Форме нужен серверный результат
При отправке читаем текст, удаляем пробелы по краям и проверяем, что канал открыт. Поле получает новый clientMessageId, созданный crypto.randomUUID(). В этом уроке идентификатор ещё не связан с отдельным подтверждением, но формирует будущий договор. После передачи формы очищаем textarea, а видимое сообщение добавляем только из пришедшего серверного события. Так страница не рисует собственное предположение вместо принятого сообщения.
В ожидании результата интерфейс остаётся простым. Надпись «Соединение открыто» относится к транспорту; она не гарантирует принятия каждой команды. Если сервер пришлёт событие error, покажем его код. Состояния «отправлено», «принято» и «прочитано оператором» пока отсутствуют. Не стоит добавлять декоративную галочку, если её значение не поддержано договором и серверной логикой.
Для отображения используется textContent. Даже если пользователь введёт строку, похожую на HTML, она останется текстом внутри элемента списка. Использование innerHTML потребовало бы отдельной проверки и очистки разметки. Обычной переписке поддержки не нужна пользовательская HTML-разметка, поэтому выбираем самый прямой способ представить содержание. Транспортный JSON никак не защищает от опасного отображения сам по себе.
Ожидаемая последовательность
При самостоятельном открытии страницы сначала должна быть надпись «Сессия не открыта». После выбора Алисы форма создаёт сессию, появляется промежуточное состояние открытия соединения, затем надпись меняется на «Соединение открыто». Кнопка отправки становится доступной. Введите «Нужна помощь с импортом» и отправьте: ожидается строка с автором alice и тем же текстом, пришедшая от сервера.
Сервер четвёртого состояния ещё рассылает события всем клиентам. Для сравнения откройте оператора в отдельном профиле браузера. Две вкладки одного профиля делят cookie и не представляют независимые аккаунты. Пока это демонстрация канала в общей комнате; выбор обращения на странице выключен до введения комнат. Права и изоляцию нельзя проверять по внешнему виду списка, если серверная маршрутизация ещё отсутствует.
Если закрыть процесс сервера, ожидается событие закрытия канала и выключенная кнопка отправки. Нажатие на ранее доступную форму не должно вызывать send() у закрытого сокета. Повторное подключение в этом состоянии выполняется новым нажатием создания учебной сессии. Автоматический reconnect появится позднее вместе с правилами истории: сам по себе повторный handshake не восстанавливает пропущенные сообщения.
Обратите внимание на различие между закрытой вкладкой и скрытой вкладкой. Переключение на другую страницу браузера не обязательно закрывает соединение. Таймеры и события могут обрабатываться иначе, но канал продолжает существовать. Поэтому сервер позднее будет проверять живое соединение самостоятельно, а интерфейс не должен считать отсутствие фокуса выходом человека из комнаты. Присутствие является отдельной прикладной моделью.
Ограничение maxlength в HTML помогает человеку, но не ограничивает другой клиент, отправляющий JSON напрямую. Браузерный код также можно изменить в инструментах разработчика. В следующем уроке продублируем необходимые правила на серверной стороне и объясним, какие ошибки можно сообщить пользователю. Клиентская проверка сокращает лишние запросы, а серверная сохраняет договор независимо от происхождения команды.
Состояния и события клиента описаны в WebSocket API. После этого шага форма связана с фактическим состоянием связи, устаревшие callbacks не управляют новой сессией, а переписка отображается безопасным текстом. Это достаточная основа, чтобы вводить проверку команды и комнаты, не смешивая их с механикой открытия браузерного соединения.