Идентификатор команды и подтверждение
Отправка через socket.send() означает передачу данных транспорту, но не принятие команды приложением. Добавим в «Линию» подтверждение ack. Браузер сохранит незавершённое действие по clientMessageId, а сервер вернёт этот же идентификатор вместе с созданным messageId. После получения подтверждения форма сможет убрать сообщение из списка ожидающих принятия.
Восьмое состояние пока не подавляет повторную команду и не хранит журнал. Оно объясняет значение подтверждения отдельно от следующих механизмов. Код находится в stages/08; пример, зависимости и браузер не запускались, статус not_run. Все формы сообщений ниже ожидаемые. Подтверждение относится к обработке в текущем процессе и не является обещанием долговечной записи или прочтения оператором.
Два идентификатора одного действия
Браузер создаёт clientMessageId до первой передачи команды. Пока действие ожидает результата, идентификатор должен оставаться тем же. Сервер создаёт messageId, когда принимает фразу. Первый идентификатор помогает конкретному клиенту связать ответ с кнопкой отправки; второй обозначает общую запись, которую увидят участники. Если сервер возвращал бы только свой идентификатор, браузеру пришлось бы угадывать соответствие по тексту и времени.
Внутри обработчика отправки восьмого снимка используется следующий фрагмент socket.js. Объект message уже собран сервером из проверенной команды и участника сессии; отправка подтверждения стоит перед рассылкой события комнаты.
send(client, {
type: 'ack',
clientMessageId: command.clientMessageId,
messageId: message.messageId,
seq: message.seq,
duplicate: false
});
Поле seq в этом состоянии ещё отсутствует у сообщения и не попадает в сериализованный JSON. Оно получит смысл после появления журнала. Не используйте undefined-поле как нулевой номер и не стройте порядок по случайному UUID. Сериализация объекта и значение протокольного поля — разные вещи: если поле требуется договором, сервер должен явно его создавать и клиент должен знать, в каком состоянии оно гарантируется.
Ожидаемое подтверждение первой отправки имеет такую форму. Значение серверного идентификатора условное, поскольку программу не запускали.
{
"type": "ack",
"clientMessageId": "demo-command-1",
"messageId": "server-generated-id",
"duplicate": false
}
Незавершённая отправка хранится отдельно
На клиенте используется карта pending. Перед передачей команда добавляется под своим идентификатором, после ack удаляется. Видимая надпись показывает количество ожидающих подтверждения. Это отдельные данные интерфейса, а не часть серверного журнала. Пока карта находится только в памяти вкладки, её содержимое исчезнет при перезагрузке страницы и не может считаться надёжной очередью отправки.
Полный public/client.js восьмого снимка показывает место сохранения и удаления. Ветви следующих состояний управляются константой stage = 8; автоматическое восстановление связи ещё не включено.
const stage = 8;
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();
});
Почему сохраняем команду до send()? Событие ответа приходит асинхронно, но правило лучше формулировать так, чтобы подтверждение никогда не оказалось раньше локальной записи ожидающего действия. Такой порядок особенно полезен при изменении транспорта, использовании тестового канала или локального обработчика. Карта всегда сначала знает о действии, а затем получает событие его завершения; порядок жизненного цикла остаётся однозначным.
При получении ack удаляется только запись с тем же clientMessageId. Две одинаковые фразы, отправленные человеком отдельно, имеют разные идентификаторы и остаются двумя сообщениями. Дедупликация по тексту ошибочно склеила бы намеренные повторы, например два ответа «Да». Идентичность действия определяется договором отправки, а не похожим содержимым. Именно поэтому создание ID нельзя переносить на каждую сетевую попытку повторной передачи.
Что подтверждение гарантирует
В этом состоянии сервер уже проверил форму команды, доступ к комнате и подписку сокета. Он создал событие в памяти процесса и отправил подтверждение. Если сразу после этого процесс остановится, сообщения не останется в долговечном хранилище. Поэтому надпись интерфейса может говорить «принято сервером», но не «надёжно сохранено». Уверенное оформление должно отражать фактическую гарантию, иначе пользователь получит ложное представление о результате.
Подтверждение не говорит, что другой участник получил или прочитал текст. Сервер начинает рассылку после формирования события, однако сокет оператора может закрыться или его браузер может не обработать данные. Для доставки и прочтения потребовались бы отдельные прикладные подтверждения с определёнными правилами. Не стоит считать присутствие открытого соединения эквивалентом внимания человека: оно отвечает только на вопрос о техническом канале.
Существует важная неопределённость: сервер принял команду, но соединение оборвалось раньше получения ack. Клиент видит ожидающее действие и не знает, произошло ли изменение. Простая повторная отправка восьмого состояния создаст второе событие, поскольку сервер ещё не хранит принятые идентификаторы. Этот случай не устраняется таймером ожидания: таймер сообщает лишь, что ответ не пришёл за выбранное время, а не что операция не выполнялась.
В клиенте установлен предел двадцати ожидающих команд. Он ограничивает память и помогает не накопить бесконечное количество неопределённых отправок. При достижении предела новая фраза не передаётся, а интерфейс просит дождаться подтверждений. Этот локальный предел не защищает сервер от другого клиента, но задаёт разумную границу собственной очереди интерфейса. Серверные лимиты частоты рассмотрим отдельно.
Если команда отклонена, текущий общий ответ error не содержит её идентификатора. Карта pending поэтому не удаляет действие автоматически. Это известное ограничение учебной реализации: ошибочное сообщение остаётся неопределённым до пересоздания сессии. Для полноценного продукта нужен отрицательный ответ, связанный с валидным ID, и интерфейс исправления или отмены. Нельзя удалять все pending при одной ошибке, потому что другие команды могли быть приняты независимо.
Серверное событие message и ack могут обрабатываться интерфейсом как два независимых завершения разных задач. Ack освобождает ожидающую команду, а message добавляет запись разговора. Если объединить их в один обработчик, входящее сообщение другого человека могло бы случайно удалить своё pending по похожему тексту. Если добавлять строку только по ack, отправитель не увидит единый серверный объект, а остальные участники получат другой путь отображения. Раздельная обработка сохраняет один источник содержания для всей комнаты и точную связь подтверждения с действием. При разрыве полезно также сохранять набранный новый черновик отдельно от уже переданной pending-команды: это разные намерения пользователя, которые не должны автоматически заменять друг друга или повторяться от нового выбранного персонажа.
Транспортные свойства send() и readyState описаны в браузерном WebSocket API. Прикладной ack добавляет наш проект. После этого урока можно отличить открытое соединение от принятой команды и явно назвать неопределённый результат разрыва. Следующий механизм — журнал — создаст основу для истории и подавления повторов в пределах памяти одного процесса.