Три предыдущих выпуска серии закрыли цикл «запрос → ответ → отрисуй»: fetch работает по модели «сходил, получил, закончил». Но есть класс задач, где сервер должен сам что-то прислать клиенту, без нового запроса с его стороны: уведомления о новом сообщении, live-счётчик, стрим токенов от модели, индикатор долгой операции. Здесь HTTP-«запрос-ответ» либо раздувают в polling (браузер каждые 2 секунды переспрашивает «ну что?»), либо меняют саму модель соединения.
Это статья про два официальных long-lived канала и один «почти-канал»:
- SSE (Server-Sent Events) — сервер пишет текстовые события в один
text/event-stream, браузер (EventSource) читает и переподключается сам; - WebSocket — двусторонний канал поверх HTTP-handshake, с бинарными фреймами и своей протокольной механикой;
- плюс, как «третий путь», — стриминг ответа через
response.body/ReadableStream(он же даёт LLM-токен-стрим) — кратко для сравнения, подробно в статье про Fetch.
К октябрю 2026 оба API есть во всех актуальных десктопных и мобильных браузерах; точные версии внедрения отдельных нюансов (например, Last-Event-ID поведение, binaryType) сверяйте на caniuse — номера релизов мы здесь сознательно не проставляем. Всё разобранное ниже — стандартный язык (EventSource, WebSocket), без библиотек; для «своего» live-виджета они хорошо садятся на Web Components из прошлого выпуска.
Часть I. Зачем нужен long-lived канал
1. Проблема polling-а
Грубый путь: setInterval(async () => { const r = await fetch('/api/feed'); … }, 2000). Работает, но:
- задержка: среднее время обнаружения события ≈ половина интервала; на быстрых каналах неприемлемо;
- трафик: каждый цикл — полный HTTP-запрос с заголовками, даже если ничего нового;
- нагрузка на сервер: «кликов» в разы больше полезной работы;
- ставит в тупик прокси/CDN кэшированием ответов.
Polling оправдан ровно в одном случае: канал не выдержит долгоживущего соединения (агрессивный reverse-proxy с таймаутом, клиент за жёстким NAT без websocket) — и тогда берут long-poll (запрос висит до первого события или до таймаута сервера, потом сразу переспрашивает). Long-poll даёт почти ту же задержку, что push, но на полунулевом HTTP-протоколе с полным переоткрытием.
2. Три формы «живых данных»
| Модель | Направление | Транспорт | Дуплекс | Готовность |
|---|---|---|---|---|
| Polling / long-poll | клиент тянет | обычный HTTP | нет | всегда (это HTTP) |
SSE (EventSource) | сервер → клиент | HTTP + text/event-stream | однонаправленный | везде |
WebSocket (WebSocket) | в обе стороны | HTTP-handshake → ws:///wss:// | полный дуплекс | везде |
Стриминг ответа (response.body) | сервер → клиент (в рамках одного GET) | HTTP chunked / ReadableStream | однонаправленный, без реконнекта из коробки | везде |
Последняя строка важна: стриминг токенов LLM — это не SSE и не WS, а просто чтение response.body как ReadableStream (чунки приходят по мере генерации). У него нет авто-реконнекта и нет push «после закрытия соединения», но для «нажал Generate → смотрю токены» он идеален и не требует нового серверного протокола.
Часть II. SSE: EventSource — самый дешёвый push
2.1 Формат на проводе
SSE — это обычный HTTP-ответ с заголовком Content-Type: text/event-stream; charset=utf-8, где тело — поток текстовых «кадров» формата поле: значение, разделённых пустой строкой:
data: привет\n
\n
id: 42\n
event: chat\n
data: {"text":"hello","from":"bob"}\n
\n
retry: 3000\n
\n
- одна пустая строка = «событие готово, можно диспатчить»;
data:— тело; можно несколькоdata:строк подряд — они склеятся через\n;event:— тип события (по умолчаниюmessage);id:— идентификатор (браузер запомнит и пришлёт при реконнекте);retry:— рекомендуемая пауза переподключения в мс;- строка, начинающаяся с
:(двоеточие, без пробела) — комментарий: им делают heartbeat, чтобы прокси не убивал «тихое» соединение.
2.2 Клиент: EventSource
const es = new EventSource('/sse/stream'); // метод только GET, кастомных заголовков нет
es.onopen = () => console.log('канал открыт');
es.onmessage = (e) => console.log('default event:', e.data, 'id:', e.lastEventId);
es.onerror = (e) => console.log('обрыв, браузер сам реконнектится');
// именованное событие (`event: chat` на сервере):
es.addEventListener('chat', (e) => {
const msg = JSON.parse(e.data);
// безопасный рендер — textContent (см. статьи про Fetch и XSS)
document.querySelector('#feed').insertAdjacentText('beforeend', msg.text + '\n');
});
readyState у EventSource: CONNECTING (0) → OPEN (1) → CLOSED (2). Отличия от WebSocket — см. таблицу в §5.1 ниже.
2.3 Серверный пример (PHP-FPM + nginx)
Самое важное в SSE — что не буферизовать. nginx по умолчанию включает proxy_buffering, и «стрим» станет одним ответом в конце.
<?php
// /sse/stream
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // nginx: выключить буферизацию (без конфа)
header('Connection: keep-alive');
@ob_end_flush(); // сбросить буферы PHP
// 1) сразу: «догнать» что пропустили (последний id пришёл в заголовке Last-Event-ID)
$last = $_SERVER['HTTP_LAST_EVENT_ID'] ?? '0';
while (true) {
$row = wait_for_new_row_since((int)$last, 15); // ваш источник, ждёт до 15 сек
if ($row !== null) {
echo "id: {$row['id']}\n";
echo "event: {$row['event']}\n"; // 'message'/'chat'/...
echo 'data: ' . json_encode($row['payload'], JSON_UNESCAPED_UNICODE) . "\n\n";
$last = (int)$row['id'];
} else {
echo ": ping\n\n"; // heartbeat-комментарий
}
flush(); // отдать кадр в сокет немедленно
}Ключевые детали: flush() после каждого кадра, X-Accel-Buffering: no (или proxy_buffering off; в конфиге nginx), таймаут на ожидание строки меньше, чем таймаут коннекта. И PHP-FPM воркер держится этим соединением — на больших нагрузках SSE на FPM стоит менять на рабочий «long-living» процесс (Swoole/RoadRunner, Go, Node) или на отдельный пул FPM только под stream-эндпоинты, иначе вы «съедите» worker pool.
2.4 Реконнект и Last-Event-ID
Когда TCP-соединение рвётся (не из-за явной ошибки), браузер сам переоткрывает EventSource и в заголовке Last-Event-ID присылает значение последнего полученного id:. Это даёт «догони меня» без потери событий: сервер отдаёт всё с id больше переданного. Поэтому в SSE всегда ставьте монотонный id: (номер строки, timestamp-коррелят) — иначе реконнект теряется и вы получите дыру в потоке.
2.5 Чего в SSE нет
- POST / PUT / DELETE: только GET. Тело запроса не передать (можно query-параметры или потом подписаться через cookie).
- Кастомных заголовков — в
EventSourceнет опцииheaders,body,credentialsпо умолчанию. Для токена в заголовкеAuthorizationSSE не годится — там нужен WebSocket (см. §6.4, где и описана проблема) или стриминг черезfetch. - Бинарных данных: поток текстовый (UTF-8). Файлы — только через base64/URL.
- Контроля реконнекта руками: он внутри браузера (хотя можно закрыть и открыть снова — §7.1).
- Одного лимита HTTP/1.1 не обошёл: браузер разрешает ~6 параллельных соединений на origin по HTTP/1.1, и SSE съедает одно, поэтому много SSE-вкладок на один домен «подохнут». На HTTP/2 это снято (мультиплексирование) — один TCP-коннект на все стримы. На HTTP/1.1 держите один общий SSE-стрим на страницу и роутите события внутри по
event:, а не открывайте NEventSource.
2.6 CORS и куки
new EventSource(url, { withCredentials: true }) разрешает слать куки; сервер обязан ответить Access-Control-Allow-Origin: <точный origin> + Access-Control-Allow-Credentials: true (нельзя * вместе с withCredentials).
2.7 SSE внутри Web Components
Компонент из прошлого выпуска логично открывает SSE в connectedCallback и закрывает в disconnectedCallback (строго один EventSource на инстанс, иначе утечка):
class LiveTicker extends HTMLElement {
connectedCallback() {
this._es = new EventSource('/sse/ticker');
this._es.addEventListener('tick', (e) => {
const v = JSON.parse(e.data).value;
(this.shadowRoot ??= this.attachShadow({ mode: 'open' })).textContent = String(v);
});
}
disconnectedCallback() {
this._es?.close(); // иначе браузер будет держать стрим даже после удаления
this._es = null;
}
}
customElements.define('live-ticker', LiveTicker);
Часть III. WebSocket: полный дуплекс
3.1 От handshake до канала
WebSocket живёт поверх HTTP: клиент шлёт GET с «апгрейд»-заголовками, сервер отвечает 101 Switching Protocols, и после этого TCP-соединение перестаёт быть HTTP и становится отдельным двунаправленным каналом с фреймами:
GET /ws/chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 nonce>
Sec-WebSocket-Version: 13
Origin: https://codestack.space
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <hash от key>
Sec-WebSocket-Accept — это SHA-1 от key + GUID — защита от «случайного» апгрейда обычного кэша/прокси; клиент его проверяет.
3.2 Клиент: WebSocket
const ws = new WebSocket('wss://codestack.space/ws/chat');
// const ws = new WebSocket(url, ['chat-v2']); // субпротокол для handshake (см. §3.7)
ws.binaryType = 'arraybuffer'; // бинарные фреймы → ArrayBuffer (по умолчанию 'blob')
ws.onopen = () => ws.send(JSON.stringify({ op: 'join', room: 'main' }));
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
handle(JSON.parse(e.data));
} else {
handleBinary(e.data); // ArrayBuffer, если binaryType='arraybuffer'
}
};
ws.onclose = (ev) => {
// ev.code (см. §3.6), ev.reason, ev.wasClean
scheduleReconnect(ev);
};
ws.onerror = () => { /* событие error без деталей; реконнект — в onclose */ };
ws.readyState: CONNECTING (0), OPEN (1), CLOSING (2), CLOSED (3). send() в CONNECTING бросит InvalidStateError; перед отправкой проверяйте готовность:
function safeSend(ws, payload) {
if (ws.readyState === WebSocket.OPEN) ws.send(payload);
else ws.addEventListener('open', () => ws.send(payload), { once: true });
}
3.3 Закрытие и коды статуса
ws.close(1000, 'bye'); // code и причина (UTF-8, ≤123 байт)
Кодекс: 1000 нормальное, 1001 going away, 1012 restart, 1013 retry later, 1015 TLS (зарезервирован, onclose его не отдаёт). Клиент сам может ставить только 1000 или 3000–4999 (остальные зарезервированы/для сервера). При некорректном коде — будет error. wasClean в onclose говорит, был ли корректный close-фрейм, или соединение «лопнуло».
3.4 Backpressure: bufferedAmount
У клиента есть очередь отправки. Если вы лопатите в send() быстрее, чем уходит в сокет, ws.bufferedAmount растёт, и это память клиента. Паттерн «drain» — не слать новый кадр, пока буфер не опустел:
function sendWithBackpressure(ws, chunks, done) {
const i = 0;
const flush = () => {
if (i >= chunks.length) { done?.(); return; }
if (ws.bufferedAmount < 64 * 1024) { // порог по вкусу
ws.send(chunks[i++]);
flush();
} else {
setTimeout(flush, 16); // вернёмся на следующем тике
}
};
flush();
}
Серверная backpressure-механика в WebSocket-протоколе тонкая (есть flow-control на уровне TCP); если сервер шлёт быстрее, чем клиент читает, у клиента растёт receive-буфер. Держите сообщения разумного размера.
3.5 Heartbeat
WS-протокол знает ping/pong-фреймы (для keepalive и обнаружения мёртвого соединения), но JS API их не показывает — браузер шлёт/принимает их сам. Поэтому на прикладном уровне heartbeat обычно делают «своим» сообщением: раз в N секунд send({op:'ping'}), сервер отвечает pong; если pong не пришёл за 2 timeout — ws.close() и реконнект. Это защищает от «zombie-коннекта»: TCP жив, а логический канал задохнулся на полпути.
3.6 Субпротоколы (и передача авторизации, см. §6.4)
const ws = new WebSocket('wss://…', ['access_token.' + token, 'chat-v2']);
Список субпротоколов уходит в заголовке Sec-WebSocket-Protocol handshake — это один из немногих мест, куда реально донести токен при WS-подключении (см. §6.4: заголовки/методы в WS недоступны). Сервер выбирает один и возвращает его в Sec-WebSocket-Protocol; если не вернёт — браузер закроет соединение.
3.7 WebSocket vs EventSource: readyState-таблица (сводка)
| Свойство | EventSource (SSE) | WebSocket |
|---|---|---|
| Направление | сервер → клиент | оба |
| Данные | текст UTF-8 | текст + бинарные (ArrayBuffer/Blob) |
| Метод/заголовки | только GET, без кастомных | HTTP-handshake, тело нет |
| Реконнект | авто + Last-Event-ID | руками (см. §5) |
| Типы событий | тип через event: | только сообщение, типы — в payload |
| Прокси-требования | обычное HTTP, нужен X-Accel-Buffering | нужен Upgrade-aware proxy (proxy_pass + Upgrade headers в nginx) |
Часть IV. Когда SSE, когда WebSocket, когда стриминг
4.1 Выбор за 30 секунд
Серверу надо писать клиенту БЕЗ запроса с его стороны?
├─ нет → fetch / ReadableStream (стриминг ответа: токен-генерация, большой CSV)
└─ да →
├─ поток только «сервер → клиент» и это текст/JSON-события?
│ └─ SSE. Меньше кода, авто-реконнект, догоняет по id.
└─ нужен «клиент → сервер» тоже (чат, совместное редактирование, игры)?
└─ WebSocket. Готовьтесь руками: реконнект, heartbeat, backpressure.
4.2 Практические подсказки
- LLM-токены / «печатающийся ответ» — это не SSE (вы не ждёте push от сервера, вы сами инициировали и читаете ответ). Правильно:
fetch(..., {method:'POST'})+for await (const chunk of res.body)/TextDecoderStream(см. статью про Fetch). SSE был бы оправдан, если бы ответ генерился асинхронно в фоне (задача в очереди, браузер слушает прогресс). - Уведомления / счётчики / статусы — SSE.
- Чат / live-редактирование / мультиплеер / торговый терминал — WebSocket.
- Загрузка больших файлов с прогрессом — ни то, ни это, а
ReadableStream(upload) + стриминг ответа. - Долгая фоновая задача (экспорт, рендер) — SSE-прогресс + обычный POST для старта, это «push + poll» гибрид без дуплекса.
4.3 Long-poll как fallback
Если инфраструктура убивает долгие соединения (агрессивный балансировщик) или нужен строгий HTTP-only транспорт: long-poll (fetch висит до события или до 20 сек, потом немедленный новый запрос). По задержке почти как push, но дороже по TCP/TLS и без догона (Last-Event-ID).
Часть V. Надёжность: реконнект, visibility, backoff
5.1 Почему «авто-реконнект» в SSE — не всё
Браузер переподключает SSE, но если сервер вернул 404/401 или неверный content-type, EventSource уйдёт в CLOSED без ретраев. Значит логику «догнать и не пропустить» всё равно держите руками: слушайте onerror, следите за readyState === CLOSED и при необходимости new EventSource(...).
5.2 Экспоненциальный backoff + jitter (для WS)
Реконнект по одному и тому же «сейчас же снова!» убивает сервер после массового обрыва (thundering herd). Классика — экспоненциальный рост с ограничением и джиттером:
function connect(url) {
const ws = new WebSocket(url);
let attempt = 0, timer;
const delay = () => {
attempt = Math.min(attempt + 1, 6); // потолок 2^6 = 64 сек
const base = Math.min(1000 * 2 ** attempt, 64000);
const jitter = Math.random() * 1000; // размазываем одновременные реконнекты
return base + jitter;
};
const reconnect = () => {
clearTimeout(timer);
timer = setTimeout(() => connect(url), delay()); // рекурсия на свежем инстансе
};
ws.onopen = () => { attempt = 0; }; // удалось — сбросить счётчик
ws.onclose = reconnect;
ws.onerror = () => { try { ws.close(); } catch {} }; // закроется → onclose → reconnect
return ws;
}
Важно: на onerror не открывайте новое соединение сразу — дождитесь onclose, иначе при сетевой ошибке получится двойной реконнект.
5.3 Вкладка «спрятана» — не держите канал
Браузер троттлит/замораживает фоновые таймеры и рвёт соединения; пользователь видит «нет новых сообщений». Правильная политика: на document.addEventListener('visibilitychange') при hidden — отложить реконнект; при visible — «свежий старт» (WS переподключить, SSE догнать по Last-Event-ID или по since=<ts> в query). Для SSE на hidden иногда закрывают канал сами: es.close(), при visible — создать новый.
5.4 Одинаковый ключ реконнекта и идемпотентность
Множественные реконнекты + «догон» по id/Last-Event-ID требуют идемпотентного применения сообщений на клиенте: храните lastSeenId и отбрасывайте id ≤ lastSeenId. Иначе после разрыва на одинаковых событиях в чате задублируется текст.
Часть VI. Безопасность потоков
6.1 WebSocket — не под CORS (главная грабля)
fetch подчиняется same-origin/CORS, и это «защита по умолчанию»: чужой сайт не прочитает ваш API без явного Access-Control-Allow-Origin. WebSocket так не защищён: браузер не блокирует открытие wss:// на чужой origin. От вредоносного веб-сайта вас отделяет только то, что сервер проверил заголовок Origin (или Cookie-сессию + CSRF-токен). Поэтому на сервере обязательно:
# nginx: пушать origin на бэкенд
proxy_set_header Origin $http_origin;
# на сервере WS перед апгрейдом:
if (!isAllowedOrigin(req.headers.Origin)) return 403; // allow-list своих origins
Без проверки Origin любой сайт с JS сможет подключиться к вашему wss:// и читать ваш поток (или писать в чат от имени авторизованного пользователя через его cookie-сессию — классический WS-CSRF). SSE уязвим меньше: EventSource без withCredentials не шлёт куки, но с withCredentials + Access-Control-Allow-Origin: * (без куки) любой origin сможет читать ваш стрим — для чувствительных данных никогда не делайте CORS-открытым SSE.
6.2 XSS: не рендерите поток в innerHTML
Сообщения из WS/SSE — это данные извне. Вставляйте через textContent/insertAdjacentText, а при разметке — белый список тегов + DOMPurify (см. статьи про XSS и Fetch). Ни eval в JSON.parse, ни new Function от тела кадра.
6.3 Всегда wss:// / https://
ws://не шифрует и в HTTPS-странице даст mixed-content (браузер заблокирует).- SSE на
http://из-под HTTPS-страницы то же самое. В prod — толькоwss:///https://. - Проверка на сервере TLS-сертификата (и на клиенте —
wssотhttpsorigin) обязательна; не игнорируйтеSec-WebSocket-Protocolmismatch.
6.4 Авторизация: куда положить токен
В WS-рукопожатии клиент не может задать заголовок Authorization (и в SSE тоже). Варианты, от лучшего к худшему:
- Первое сообщение после
onopenс токеном ({op:'auth', token}) — токен живёт в памяти JS, не в URL. - Субпротокол
['access_token.'+token](§3.6). - HttpOnly-cookie + проверка
Origin(если серверная сессия в куке) — но кука в WS шлётся автоматически, поэтому безOrigin-валидации это дыра (§6.1). - Токен в query (
wss://…?token=…) — плохо: попадает в логи, вReferer, в кэш. Допустимо только short-lived одноразовый ticket.
SSE с withCredentials = вариант «кука». Если нужен Authorization: Bearer в SSE — SSE не подойдёт, идите в WS или в стриминг fetch (там заголовки есть).
6.5 Rate-limit и размер сообщений
Сервер WS/SSE обязан лимитировать частоту и размер: «100 сообщений в секунду», «сообщение ≤ 64 КБ». Иначе один клиент флудом забивает память/канал, а bufferedAmount (§3.4) превращается в DoS на клиенте.
Часть VII. Практика: live-ticker на SSE с реконнектом и health
7.1 Полный компонент
class LiveTicker extends HTMLElement {
static observedAttributes = ['src'];
#es = null;
#retry = 0;
attributeChangedCallback(_, o, v) { if (o !== undefined) this.#connect(v); }
connectedCallback() { this.#connect(this.getAttribute('src')); }
disconnectedCallback() { this.#close(); }
#close() {
this.#es?.close();
this.#es = null;
this.#retry = 0;
}
#connect(url) {
this.#close();
if (!url || !this.isConnected) return; // reconnect только если элемент в DOM
this.#es = new EventSource(url, { withCredentials: true });
this.#es.addEventListener('open', () => {
this.#retry = 0; // канал живой — счётчик в ноль
this.#setState('live');
});
this.#es.addEventListener('tick', (e) => {
const v = JSON.parse(e.data).value;
this.#root().textContent = v; // safe render
});
this.#es.addEventListener('error', () => {
if (this.#es.readyState === EventSource.CLOSED) {
// браузер сам не переоткроет (401/404/415/не text/event-stream) —
// переподключаемся руками с backoff
this.#backoff(url);
} else {
this.#setState('retry'); // браузер сам реконнектится
}
});
}
#backoff(url) {
this.#retry = Math.min(this.#retry + 1, 6);
const delay = Math.min(1000 * 2 ** this.#retry, 60000) + Math.random() * 500;
setTimeout(() => this.#connect(url), delay);
}
#root() { return this.shadowRoot ?? this.attachShadow({ mode: 'open' }); }
#setState(s) { this.setAttribute('state', s); } // снаружи: :host([state="retry"]) …
}
customElements.define('live-ticker', LiveTicker);
<live-ticker src="/sse/ticker"></live-ticker>
Что учтено по статье: один EventSource на инстанс и снятие при disconnect (§2.7), догон по авто-Last-Event-ID (браузер, §2.4), обработка «CLOSED» с ручным backoff+jitter (§5.2), проверка isConnected перед reconnect (§5.1), withCredentials+ Origin-валидация на сервере (§6.1/§6.4), safe-render через textContent (§6.2).
7.2 Чек-лист по интеграции на бэкенде
- nginx: SSE —
proxy_buffering off;/X-Accel-Buffering: no; WS —proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";; поднятьproxy_read_timeoutвыше heartbeat. - PHP-FPM: не держать SSE-стрим в основном пуле (worker starvation) — отдельный long-lived процесс/пул или переход на Swoole/RoadRunner.
- heartbeat: период <
proxy_read_timeout, чтобы не рвалось. Last-Event-ID/since: сервер умеет «догнать» после реконнекта.- Origin-allow-list для WS, куки + CSRF для SSE с
withCredentials. - wss/https, лимиты частоты и размера сообщений.
Чек-лист: готовность и грабли
- Не polling, если есть канал.
setInterval(fetch)на «ну что?» — задержка, трафик, нагрузка. Long-poll только как fallback. - SSE = только GET, только текст, только сервер→клиент. Нет POST, нет кастомных заголовков (
Authorization!), нет бинарных. Для bearer-токена — WS или стримингfetch. id:в SSE обязателен (монотонный) — без него авто-реконнект теряется и дыра в потоке; сервер читаетLast-Event-ID.- Heartbeat через
: ping-комментарий — иначе прокси/CDN убьют «тихий» SSE по таймауту. - nginx
proxy_buffering off/X-Accel-Buffering: noдля SSE;Upgrade/Connectionдля WS. - HTTP/1.1: один общий SSE-стрим, а не N
EventSource(съедает лимит ~6 conns на origin). HTTP/2 снимает это. - PHP-FPM worker не должен держать долгие SSE на всем пуле — отдельный long-living процесс или отдельный пул.
- WS: проверять
readyState === OPENпередsend(). ВCONNECTINGон броситInvalidStateError. - WS: авто-реконнекта нет — пишите руками, на
onclose, с экспоненциальным backoff + jitter и сбросом счётчика наopen. Не открывайте второй коннект наonerrorдоonclose. - WS: heartbeat прикладным
ping/pong— JS API не показывает протокольные ping/pong; обнаружение zombie-канала на вас. - WS:
bufferedAmount= backpressure. Не шлите в бесконечную очередь, ждите дренаж. - WS не под CORS. Сервер обязан валидировать
Origin, иначе WS-CSRF/чтение чужого потока. SSE — куки +Access-Control-Allow-Originс осторожностью. - Токен авторизации: первый
auth-месседж / субпротокол / cookie+Origin. Query-параметр — только short-lived одноразовый ticket (уходит в логи/Referer). wss:///https://всегда, иначе mixed-content блокировка в HTTPS-странице.- Safe-render потока:
textContent/insertAdjacentText; никакогоinnerHTML/evalот тела кадра. - visibility: hidden → не держать канал, visible → реконнект/догон.
- Идемпотентность при догоне: отбрасывайте
id ≤ lastSeenId, иначе дубли сообщений после разрыва. - WC-компонент:
EventSource/WebSocketвconnectedCallback,close()вdisconnectedCallback— строго один на инстанс.
Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют WHATWG/WebSocket API и Server-Sent Events (HTML Living Standard,
EventSource/WebSocket). Поведение в старых/мобильных браузерах и в специфичных прокси (буферизация,Last-Event-IDна реконнект) сверяйте по MDN и caniuse для вашей аудитории.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
