Обычный индикатор загрузки ничего вам не говорит. Когда задача ИИ растягивается на минуты или возвращается в очередь на третью попытку, вам необходимо видеть текущее состояние. Server-Sent Events (SSE) обеспечивают такую видимость без накладных расходов на рукопожатие, характерных для WebSockets, и без сложности реализации длинных опросов (long polling). Сервер держит одно HTTP-соединение открытым и отправляет текстовые обновления по мере изменения состояния. Клиент считывает их по мере поступления.

Если соединение прервется, вы, вероятно, не захотите начинать всё сначала. Грамотно реализованный SSE-поток «помнит», на чем вы остановились. Только с помощью Node.js 20 и стандартной библиотеки вы можете настроить это самостоятельно. Никакие внешние пакеты не требуются.

Как выглядит формат передачи данных

Сообщение SSE — это простой текст. Сервер записывает три вещи: необязательное имя события, обязательное поле data и поле id, которое становится вашей точкой сохранения. Каждая запись заканчивается двумя символами новой строки — пустой строкой, которая служит границей.

В нормальном состоянии поток данных в канале выглядит так:

id: 14
event: status
data: {"phase":"testing","progress":43}

id: 15
event: status
data: {"phase":"retrying","attempt":2}

Браузерный клиент EventSource считывает эти строки автоматически. Он создает событие для каждого блока и сохраняет последний id во внутренней памяти. Если TCP-соединение оборвется, клиент подождет, переподключится и отправит сохраненный идентификатор обратно на сервер в заголовке Last-Event-ID. Именно благодаря этому заголовку данный паттерн и работает. Без него у вас не будет надежного курсора.

Настройка сервера на Node.js

Встроенный модуль Node.js http может обрабатывать это напрямую. Когда поступает запрос, установите правильные заголовки, чтобы клиент понимал, что это поток, а не страница:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Отключите буферизацию. Прокси-серверы и фреймворки иногда группируют ответы, что убивает ощущение работы в реальном времени, поэтому сбрасывайте буфер (flush) после каждого фрагмента.

Сначала отправьте ID, затем тип события, затем полезную нагрузку, а затем завершающую пустую строку. Порядок важен лишь в том, что ID должен прийти до пустой строки, чтобы клиент смог его зафиксировать. Если вы используете нативный метод response.write(), вывод будет буквально следующим:

response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);

Этот завершающий \n\n — не декорация. Парсеры SSE воспринимают его как терминатор записи. Пропустите его, и клиент будет зависать в ожидании новых данных.

Курсор — это всё

Новое HTTP-соединение не гарантирует получение актуального состояния. Когда клиент переподключается, заголовок Last-Event-ID сообщает вам последнее полученное им сообщение. Ваша задача — возобновить передачу со следующего сообщения, а не с самого начала.

Это означает ведение упорядоченного лога или журнала событий на стороне сервера. Для демо подойдет массив в памяти. В продакшене вам понадобится что-то более надежное — например, запись в лог базы данных, поток Redis или журнал упреждающей записи (write-ahead journal), — потому что перезапуск сервера не должен стирать историю и заставлять каждого клиента начинать с нуля.

Индексируйте события с помощью монотонно возрастающего целого числа или ULID. При переподключении запрашивайте события, где id > lastEventId, и воспроизводите их по порядку. Если у вас накопились сотни сообщений, можно добавить небольшую искусственную задержку или объединять их в пакеты, но отправляйте их в порядке очереди (от старых к новым), чтобы клиент мог восстановить состояние хронологически.

Будьте готовы к дубликатам

Сети ненадежны. Сервер может отправить событие, потерять подтверждение TCP и отправить его снова после таймаута. С самого начала проектируйте систему с расчетом на доставку «как минимум один раз» (at-least-once delivery).

На стороне клиента дедупликация обходится дешево. Используйте Map, где ключом будет ID события. Когда приходит новое событие, проверьте карту. Если ID уже существует, молча отбросьте дубликат. Поскольку ваш сервер назначает детерминированные ID, это делает дубликаты безвредными. Карта не должна расти бесконечно. Как только вы подтвердите, что событие безопасно обработано, удаляйте старые ID. Скользящего окна на несколько сотен записей обычно достаточно для браузерных клиентов.

Когда срок действия курсора истекает

Рано или поздно клиент переподключится спустя часы или даже дни. Если ваш буфер истории охватывает только последнюю тысячу событий, а клиент отстал на две тысячи, восстановить пропущенные данные будет невозможно.

Не транслируйте частичную историю. Это приведет к несогласованному состоянию клиента. Вместо этого обнаружите истекший курсор и отправьте полный снимок состояния (snapshot) в качестве следующего события. Снимок должен содержать новый курсор, который привяжет клиента к текущему состоянию. После этого передача дельт в реальном времени продолжится в обычном режиме. Четко задокументируйте это пограничное состояние в вашем протоколе, чтобы клиентский код знал, когда нужно сбросить локальную модель, а не просто добавлять данные.

Защитите поток

Открытые SSE-эндпоинты — привлекательная цель. Любой может удерживать соединение, а повторные запросы могут усилить нагрузку на чтение в вашем хранилище.

Gate the endpoint with proper authorization. Because the browser EventSource does not support custom headers, pass the token in the query string or use cookies with strict SameSite policies. Validate the token before you allocate stream resources.

Set history limits and per-user quotas. Cap the number of stored events per task, and cap the number of concurrent connections per client. Log disconnects and replays so you can spot a rogue client hammering your cursor endpoint.

The pattern travels

This approach is not trapped inside HTTP. The same rules apply when you move to WebSockets, message queues, or agent-to-agent interfaces. The transport changes—you might use binary frames or topic subscriptions—but the underlying problem stays identical. You need a cursor, a durable log, at-least-once semantics, client deduplication, and a fallback to full snapshots when the cursor goes stale. Solve state convergence once, and you can ship it over TCP, WebSocket, or a broker like RabbitMQ without redesigning the core logic.

Keep it simple

Server-Sent Events work because they ride on ordinary HTTP. Proxies understand them. Load balancers can health-check them. Debugging is as easy as curl. But that simplicity disappears if you ignore the edge cases. Build the cursor. Expect replays. Deduplicate on the client. snapshot when history runs out. Do that, and your long-running AI tasks will report their progress honestly, even through spotty Wi-Fi, server restarts, and the occasional overnight browser sleep.

Source: Build a Reconnecting SSE Task Stream with Node.js

Join the discussion: GyaanSetu AI Community