SSE (Server-Sent Events) — поток текстовых событий от сервера к клиенту в HTTP-ответе. Подходит для прогресса задач, уведомлений и постепенного вывода текста. Браузерный EventSource умеет переподключаться, но хранение событий, устранение дублей и восстановление после долгого отсутствия остаются частью вашего контракта.
Нажали «Сформировать отчёт». Сервер прошёл этапы «собираю данные», «считаю», «готово». Хочется показывать их по мере выполнения, а не спрашивать статус каждую секунду. При этом пользователь почти ничего не отправляет обратно. Я бы сначала рассмотрел SSE: для потока в одну сторону отдельный двунаправленный протокол может оказаться лишним.
Что передаётся по сети?
Клиент открывает запрос, сервер отвечает с Content-Type: text/event-stream и продолжает писать в тело. Формат — UTF-8, поля построчно, пустая строка завершает событие. data — полезная нагрузка, event — имя типа, id — идентификатор, retry — задержка переподключения в миллисекундах. Это правила стандарта SSE.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
retry: 3000
id: 81
event: report.progress
data: {"reportId":"r-42","revision":7,"percent":60}
: heartbeat
id: 82
event: report.completed
data: {"reportId":"r-42","revision":8,"downloadUrl":"/api/reports/r-42/file"}
Это пример прикладных событий для отчёта, а не стандартные типы SSE. JSON внутри data выбрали мы. Строка с двоеточием — комментарий, обработчик приложения её не получает. Пустая строка после последнего события обязательна; один сетевой пакет не равен одному событию.
Что делает EventSource?
const feed = new EventSource("/api/reports/r-42/events");
feed.addEventListener("report.progress", (event) => {
const update = JSON.parse(event.data);
// Проверить схему и ревизию, затем обновить экран.
});
feed.addEventListener("report.completed", () => {
feed.close();
// Перечитать итоговый ресурс отчёта и показать ссылку.
});
feed.onerror = () => {
// Показать потерю связи; это ещё не провал самого отчёта.
};
// При уходе со страницы тоже вызвать feed.close().
Фрагмент показывает подключение, а не полный клиент. Именованные события слушают через addEventListener; onmessage обрабатывает тип message: он используется по умолчанию, когда имя не задано, или указан явно как event: message. После обычного обрыва EventSource пытается восстановить подключение; close() останавливает его. Ответ HTTP 204 на подключение также прекращает автоматические повторы. Детали — в руководстве MDN по SSE.
Переподключение — это уже гарантия доставки?
Нет. EventSource при восстановлении передаёт известный ему Last-Event-ID. Серверу ещё надо найти события после этой позиции. Формат SSE не создаёт журнал и не подтверждает, что ваш обработчик успешно сохранил данные. Новый объект после перезагрузки страницы тоже не получает историю старого автоматически.
sequenceDiagram participant C as Браузер participant S as Сервер participant L as Журнал S-->>C: id 81 progress Note over C,S: Сеть оборвалась S->>L: сохранить 82 completed C->>S: подключение, Last-Event-ID 81 S->>L: прочитать после 81 L-->>S: событие 82 S-->>C: id 82 completed
На схеме сервер сохраняет завершение отчёта, пока браузер отключён. При возврате браузер сообщает позицию 81, а сервер дочитывает журнал и отправляет 82. Без журнала переподключение восстановило бы только соединение. Смена инстанса за балансировщиком не должна уничтожать возможность такого чтения.
Для учебного контракта договоримся: журнал доступен 24 часа; повторы допустимы; состояние применяем только с более новой ревизией. Если позиции уже нет, сервер передаёт наше событие stream.reset. Клиент закрывает поток, получает снимок с курсором и заново подписывается после него. Способ передачи стартового курсора при новом подключении, например параметр after, документируем отдельно. Это наш механизм восстановления, не встроенная команда SSE.
Снимок тоже должен согласовываться с потоком
«Прочитали состояние, потом слушаем только новые события» оставляет окно потери. Снимок должен соответствовать позиции журнала, а подписка — начинаться после неё. Если клиент не успевает догнать поток до истечения хранения, нужен явный отказ или иной режим, а не бесконечный reset.
А если нужен POST и Authorization?
Нативный EventSource не даёт задать произвольный метод, тело и заголовок Authorization. Для того же origin можно использовать сессионную cookie. Для другого origin потребуется корректная настройка CORS и credentials. Долгоживущий секрет в query string — плохая замена заголовку: URL может оказаться в журналах.
Для генерации текста часто удобен fetch с POST и чтением response.body. Можно передавать SSE-формат, но автоматического поведения EventSource у такого кода нет. Нужны собственные разбор событий, отмена, обработка ошибок и стратегия восстановления. Поток байтов может разрезать UTF-8 символ, строку или JSON в любом месте; используйте потоковый декодер и буфер до границы сообщения. Работа с телом ответа описана в документации Streams API.
И отдельно решите, можно ли повторять исходный POST. Повтор генерации может создать вторую платную операцию. Для нашей задачи лучше иметь идентификатор операции и отдельно подписываться на её результат; правила повторов команд связаны с идемпотентностью.
Почему локально стримит, а в проде приходит одним куском?
Между обработчиком и экраном стоят прокси, компрессия, буферы и таймауты. Проверять надо внешний адрес всей цепочки. Для nginx бывает уместно отключить буферизацию потокового маршрута, например через X-Accel-Buffering: no; остальные посредники настраиваются отдельно. Периодические комментарии помогают поддерживать активность, но не отменяют максимальную длительность запроса у платформы. Эти практические ограничения также описаны в MDN.
В нашем отчёте heartbeat отправляется раз в 15 секунд при idle timeout 60 секунд; это пример согласованных настроек. Ограничиваем число подписок пользователя и размер очереди. Медленному экрану можно оставить последнюю ревизию прогресса, но нельзя молча выкинуть обязательные записи аудита. На HTTP/1.x отдельно проверяем лимиты соединений браузера при нескольких вкладках; HTTP/2 не делает серверные ресурсы безлимитными.
Как принимать потоковый сценарий?
- Первое и последующие события видны до закрытия ответа, в том числе через production-подобный прокси.
- Обрыв после события 81 приводит к восстановлению хвоста; повтор не откатывает процент назад.
- При устаревшем курсоре клиент пересинхронизируется и объясняет временную потерю актуальности.
- Завершение операции закрывает подписку; уход со страницы освобождает ресурсы.
- Отзыв доступа останавливает доставку, а не ждёт вечного переподключения.
- Для генерации текста различаются нормальное завершение, частичный результат и ошибка; конец соединения сам по себе не означает успех.
Если клиенту нужно часто отправлять сообщения в тот же канал, переходите к контракту WebSocket. Если достаточно редких проверок, вернитесь к polling. Общий выбор — в обзоре способов доставки.
Частые вопросы
SSE передаёт только строки, значит JSON нельзя?
Можно передавать JSON как текст в data и разбирать его на клиенте. Схему, размер и версию полезной нагрузки определяет приложение.
Можно отправить команду обратно?
Через поток SSE — нет. Можно сделать отдельный POST. Для кнопки «отменить отчёт» это вполне нормальный контракт.
Надо ли писать Transfer-Encoding: chunked?
Не как универсальное требование. Это деталь HTTP/1.1; у HTTP/2 другой способ передачи. В требованиях фиксируйте своевременную доставку событий, а не заголовок, привязанный к одной версии HTTP.