WebSocket в проде: всё, что приходится дописать вокруг одной строчки
new WebSocket(url) отлично работает на демо. Что к нему добавляется, когда чат должен пережить метро, лифт и сон ноутбука.
new WebSocket(url) - одна строчка. На демо работает идеально.
Потом ты выкатываешь онлайн-поддержку в прод, и выясняется, что вокруг этой строчки надо написать ещё четыре механизма. Иначе оператор пишет клиенту в мёртвое соединение и не понимает, почему тот молчит.
Соединение рвётся всегда
Не «может порваться», а рвётся: вайфай переключился на LTE, ноутбук ушёл в сон, прокси прибил простаивающее соединение, бэкенд выкатил релиз. Браузер сам не переподключается, это наша работа.
Только не в лоб «раз в секунду». Если бэкенд лёг, тысяча клиентов начнёт его долбить каждую секунду и не даст подняться. Нужна экспоненциальная задержка со случайным разбросом - про jitter хорошо написано у AWS: без него все клиенты синхронизируются и приходят волнами.
let attempt = 0;
function scheduleReconnect() {
const base = Math.min(1000 * 2 ** attempt, 30_000); // 1с, 2с, 4с... до 30
const delay = base * (0.5 + Math.random() * 0.5); // разброс 50-100%
attempt++;
setTimeout(connect, delay);
}
function connect() {
const ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; flushQueue(ws); };
ws.onclose = (e) => {
if (e.code === 1000) return; // закрыли сами, всё нормально
scheduleReconnect();
};
}
Мёртвое соединение выглядит как живое
Самый неприятный случай из всех. Формально readyState === OPEN, а данные не ходят - такое бывает, когда обрыв произошёл так, что FIN до нас не дошёл. Клиент уверен, что всё хорошо, и молчит часами.
Поэтому пингуем сами:
let pongTimer: number;
function startHeartbeat(ws: WebSocket) {
setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'ping' }));
pongTimer = window.setTimeout(() => ws.close(4000, 'no pong'), 5000);
}, 25_000);
}
// в обработчике сообщений
if (msg.type === 'pong') clearTimeout(pongTimer);
25 секунд взяты не с потолка: прокси и балансировщики закрывают неактивные соединения примерно через минуту (у nginx это дефолт), так что пинг должен быть заметно чаще.
Сообщения, отправленные в разрыв
Пользователь жмёт «отправить» ровно в момент реконнекта. ws.send() уходит в никуда, сообщение пропало, а человек уверен, что написал. Поэтому весь исходящий трафик идёт через очередь:
const queue: string[] = [];
function send(data: unknown) {
const payload = JSON.stringify(data);
if (ws?.readyState === WebSocket.OPEN) ws.send(payload);
else queue.push(payload);
}
function flushQueue(ws: WebSocket) {
while (queue.length) ws.send(queue.shift()!);
}
Вторая половина той же проблемы - сообщения, которые пришли, пока нас не было. Сокет их не восстановит, тут нужен обычный REST: при переподключении дозапрашиваем историю с последнего известного id. Это и есть честный ответ на «а почему после обрыва пропали сообщения».
Состояние надо показывать
Если чат не работает, человек должен это видеть, а не догадываться. Три состояния хватает:
type ConnectionState = 'connecting' | 'online' | 'offline';
Серая плашка «подключение…» и «нет связи, пробуем ещё раз». Обращений в поддержку из-за «у вас всё сломалось» после этого стало заметно меньше - люди просто ждут пару секунд.
Наружу отдаём composable
Компонентам не надо знать про очереди и реконнекты:
const { state, messages, send } = useChatSocket(roomId);
Внутри всё вышеописанное плюс onUnmounted(() => socket.close(1000)), чтобы соединение не жило после ухода с экрана. Здесь composable честно выигрывает у сервиса-синглтона: жизнь соединения привязана к жизни экрана, и забыть закрыть его сложнее.
И последнее. Если данные идут только от сервера к клиенту - уведомления, прогресс задачи, статусы - возьми лучше SSE. Браузер переподключается сам, работает поверх обычного HTTP, не требует отдельной возни с прокси. WebSocket нужен, когда трафик двусторонний и частый, во всех остальных случаях это лишняя сложность.