Спіннер завантаження нічого вам не повідомляє. Коли завдання ШІ триває хвилини або повертається в чергу на третю спробу, вам потрібно бачити стан процесу. Server-Sent Events забезпечують таку видимість без накладних витрат на рукостискання, притаманних WebSockets, або складної хореографії long polling. Сервер тримає одне HTTP-з'єднання відкритим і надсилає оновлення у вигляді звичайного тексту в міру змін. Клієнт зчитує їх одразу після отримання.
Якщо з'єднання розірветься, ви, ймовірно, не захочете починати спочатку. Добре побудований SSE-потік пам'ятає, на чому ви зупинилися. Ви можете реалізувати це, використовуючи лише Node.js 20 та стандартну бібліотеку. Жодних зовнішніх пакетів не потрібно.
Формат передачі даних (wire format)
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
Вбудований модуль http у Node.js може обробляти це безпосередньо. Коли надходить запит, встановіть правильні заголовки, щоб клієнт знав, що це потік, а не сторінка:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Вимкніть буферизацію. Проксі-сервери та фреймворки іноді групують відповіді, що вбиває відчуття роботи в реальному часі, тому виконуйте flush після кожного фрагмента.
Спочатку надішліть ID, потім тип події, потім дані payload, а потім завершальний порожній рядок. Порядок має значення лише в тому, що 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 stream або журналу запису (write-ahead journal), — оскільки перезапуск сервера не повинен стирати історію та змушувати кожного клієнта починати з нуля.
Індексуйте свої події за монотонно зростаючим цілим числом або ULID. Коли надходить перепідключення, зробіть запит на події, де id > lastEventId, і відтворіть їх по порядку. Якщо у вас сотні накопичених повідомлень, додайте невелику штучну затримку або групування, але надсилайте їх у порядку від найстаріших до найновіших, щоб клієнт міг реконструювати стан хронологічно.
Очікуйте дублікатів
Мережі ненадійні. Сервер може надіслати подію, втратити TCP-підтвердження і надіслати її знову після таймауту. Проєктуйте систему з розрахунком на доставку принаймні один раз (at-least-once delivery) з самого початку.
На стороні клієнта дедуплікація коштує дешево. Використовуйте Map, де ключем є ID події. Коли надходить нова подія, перевірте мапу. Якщо ID вже існує, тихо відкиньте дублікат. Оскільки ваш сервер призначає детерміновані ID, це робить дублікати нешкідливими. Мапа не повинна рости нескінченно. Як тільки ви підтвердите, що подію успішно оброблено, видаляйте старі ID. Ковзного вікна з кількох сотень записів зазвичай достатньо для браузерних клієнтів.
Коли термін дії курсору закінчується
Зрештою клієнт може перепідключитися через години або дні. Якщо ваш буфер історії охоплює лише останні тисячу подій, а клієнт відстає на дві тисячі, відтворити пропуски неможливо.
Не надсилайте лише частину історії. Це залишить клієнта в несумісному стані. Замість цього виявіть прострочений курсор і надішліть повний знімок стану (snapshot) як наступну подію. Знімок має містити новий курсор, який прив'яже клієнта до поточного стану. Після цього можна знову надсилати живі дельти (deltas) у звичайному режимі. Чітко задокументуйте цю межу у вашому протоколі, щоб код клієнта знав, коли потрібно скинути свою локальну модель, а не просто додавати дані.
Захистіть потік
Відкриті SSE-ендпоінти є привабливими цілями. Будь-хто може утримувати з'єднання, а повторні запити можуть посилити навантаження на читання вашого сховища.
Захистіть ендпоінт належною авторизацією. Оскільки браузерний EventSource не підтримує кастомні заголовки, передавайте токен у рядку запиту або використовуйте cookies із суворими політиками SameSite. Перевіряйте токен перед виділенням ресурсів потоку.
Встановіть ліміти історії та квоти для кожного користувача. Обмежте кількість збережених подій на кожне завдання та кількість одночасних з'єднань на одного клієнта. Логуйте розриви з'єднань та повтори, щоб ви могли виявити зловмисного клієнта, який засипає запитами ваш cursor-ендпоінт.
Цей підхід універсальний
Цей підхід не обмежений лише HTTP. Ті самі правила діють при переході на WebSockets, черги повідомлень або інтерфейси взаємодії агентів (agent-to-agent). Транспорт змінюється — ви можете використовувати бінарні фрейми або підписки на топіки — але суть проблеми залишається незмінною. Вам потрібен курсор, надійний лог, семантика "at-least-once", дедуплікація на стороні клієнта та перехід на повні знімки стану (snapshots), коли курсор застаріває. Вирішіть проблему збіжності станів (state convergence) один раз, і ви зможете передавати її через TCP, WebSocket або брокер на кшталт RabbitMQ без переробки основної логіки.
Дотримуйтесь простоти
Server-Sent Events працюють завдяки тому, що вони базуються на звичайному HTTP. Проксі-сервери розуміють їх. Балансувальники навантаження можуть виконувати перевірку стану (health-check). Відлагодження таке ж просте, як використання curl. Але ця простота зникає, якщо ігнорувати граничні випадки. Створіть курсор. Очікуйте повторів. Виконуйте дедуплікацію на стороні клієнта. Робіть знімки стану (snapshots), коли історія закінчується. Зробіть це, і ваші тривалі завдання ШІ будуть чесно повідомляти про свій прогрес навіть за умов нестабільного Wi-Fi, перезавантаження серверів або випадкового переходу браузера в режим сну вночі.
Джерело: Build a Reconnecting SSE Task Stream with Node.js
Приєднуйтесь до обговорення: GyaanSetu AI Community
