Пошук уроків, статей та іншого контенту
Передавайте односторонні оновлення від сервера до браузера через Server-Sent Events і відновлення з’єднання.
Server-Sent Events (SSE) — це механізм одностороннього передавання даних від сервера до браузера через довге HTTP-з’єднання.
Браузер відкриває з’єднання за допомогою EventSource, а сервер залишає HTTP-відповідь відкритою й надсилає події в міру появи:
браузер ── HTTP-запит ──> сервер
браузер <── події ─────── серверSSE підходить для:
оновлення статусу довгих операцій;
повідомлень про зміни даних;
прогресу завантаження або обробки;
сповіщень;
простих інформаційних стрічок.
На відміну від WebSocket, SSE підтримує лише напрямок
SSE використовує звичайний текстовий формат. Кожна подія завершується порожнім рядком:
event: update
id: 42
data: {"status":"ready"}
Основні поля:
event — назва події;
id — ідентифікатор події;
data — дані події;
retry — затримка перед автоматичною спробою перепідключення, у мілісекундах.
Якщо поле event не вказано, браузер передає подію через обробник message.
Для SSE сервер повинен встановити такі заголовки:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-aliveРозглянемо сервер без додаткових бібліотек. Він:
віддає HTML-сторінку;
відкриває SSE-ендпойнт /events;
надсилає оновлення кожні п’ять секунд;
зберігає останні події;
повторно надсилає пропущені події після перепідключення.
Створіть файл server.js:
const http = require('node:http');
const port = 3000;
const clients = new Set();
const history = [];
const historyLimit = 20;
let nextEventId = 1;
const html = `<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Server-Sent Events</title>
<style>
body {
font-family: sans-serif;
max-width: 700px;
margin: 40px auto;
}
#status {
font-weight: bold;
}
li {
margin-bottom: 8px;
}
</style>
</head>
<body>
<h1>Оновлення сервера</h1>
<p>Стан з'єднання: <span id="status">підключення...</span></p>
<ul id="updates"></ul>
<script>
const statusElement = document.querySelector('#status');
const updatesElement = document.querySelector('#updates');
const source = new EventSource('/events');
source.addEventListener('open', () => {
statusElement.textContent = 'підключено';
});
source.addEventListener('error', () => {
statusElement.textContent = 'з’єднання втрачено, повторна спроба...';
});
source.addEventListener('update', (event) => {
const update = JSON.parse(event.data);
const item = document.createElement('li');
item.textContent =
'#' + event.lastEventId + ': ' + update.message;
updatesElement.prepend(item);
});
</script>
</body>
</html>`;
function createEventMessage(event) {
const lines = [];
if (event.id !== undefined) {
lines.push(`id: ${event.id}`);
}
if (event.name) {
lines.push(`event: ${event.name}`);
}
lines.push(`data: ${JSON.stringify(event.data)}`);
return `${lines.join('\n')}\n\n`;
}
function sendEvent(response, event) {
response.write(createEventMessage(event));
}
function publish(name, data) {
const event = {
id: nextEventId++,
name,
data
};
history.push(event);
if (history.length > historyLimit) {
history.shift();
}
const message = createEventMessage(event);
for (const client of clients) {
if (!client.destroyed) {
client.write(message);
}
}
}
const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/') {
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
});
response.end(html);
return;
}
if (request.method === 'GET' && request.url === '/events') {
response.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
response.flushHeaders();
// Просимо браузер повторити спробу через три секунди.
response.write('retry: 3000\n\n');
clients.add(response);
const lastEventIdHeader = request.headers['last-event-id'];
const lastEventId = Number.parseInt(lastEventIdHeader, 10);
if (Number.isInteger(lastEventId)) {
// Повторно надсилаємо події, яких клієнт ще не отримав.
for (const event of history) {
if (event.id > lastEventId) {
sendEvent(response, event);
}
}
}
sendEvent(response, {
name: 'connected',
data: {
message: 'З’єднання встановлено'
}
});
request.on('close', () => {
clients.delete(response);
});
return;
}
response.writeHead(404, {
'Content-Type': 'text/plain; charset=utf-8'
});
response.end('Not found');
});
server.listen(port, () => {
console.log(`Сервер запущено: http://localhost:${port}`);
});
// Демонстраційні оновлення від сервера.
setInterval(() => {
publish('update', {
message: `Нове оновлення о ${new Date().toLocaleTimeString('uk-UA')}`
});
}, 5000);
// Коментарі не є подіями та ігноруються браузером.
// Вони потрібні, щоб проміжні проксі не вважали з’єднання неактивним.
setInterval(() => {
for (const client of clients) {
if (!client.destroyed) {
client.write(': heartbeat\n\n');
}
}
}, 20000);Запустіть сервер:
node server.jsПісля цього відкрийте в браузері:
http://localhost:3000Сторінка отримуватиме нову подію кожні п’ять секунд без повторних HTTP-запитів із клієнта.
EventSourceДля створення SSE-з’єднання браузер надає вбудований клас EventSource:
const source = new EventSource('/events');Він автоматично:
виконує GET-запит до вказаного URL;
очікує відповідь із типом text/event-stream;
обробляє вхідні події;
автоматично намагається перепідключитися після помилки.
Якщо сервер надсилає подію без поля event, її можна обробити через onmessage:
const source = new EventSource('/events');
source.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};Повідомлення такого формату:
data: {"message":"Готово"}
буде передане в onmessage.
Якщо сервер надсилає поле event, використовуйте addEventListener:
const source = new EventSource('/events');
source.addEventListener('update', (event) => {
const data = JSON.parse(event.data);
console.log('Оновлення:', data);
});
source.addEventListener('connected', (event) => {
console.log('Сервер підтвердив підключення');
});Для різних типів повідомлень зручно використовувати різні назви:
event: update
data: {"value":10}
event: notification
data: {"text":"Нове повідомлення"}
Якщо SSE-з’єднання переривається, EventSource не завершує роботу назавжди. Браузер автоматично виконує нову спробу підключення.
Сервер може вказати інтервал повторної спроби:
retry: 3000
Значення 3000 означає 3000 мілісекунд, тобто три секунди.
На клієнті можна відстежувати стан:
const source = new EventSource('/events');
source.onopen = () => {
console.log('SSE-з’єднання відкрите');
};
source.onerror = () => {
console.log('З’єднання втрачено або виконується перепідключення');
};Подія error не обов’язково означає остаточну помилку. Часто вона повідомляє, що браузер саме готується підключитися знову.
Не потрібно створювати новий EventSource у кожному виклику onerror. Це може створити кілька паралельних з’єднань:
// Неправильно: можна створити дублікати з’єднань.
source.onerror = () => {
new EventSource('/events');
};Автоматичне перепідключення відновлює канал зв’язку, але саме по собі не гарантує отримання подій, які були надіслані під час розриву.
Для цього сервер і клієнт використовують ідентифікатори подій.
Сервер додає до події поле id:
id: 101
event: update
data: {"value":42}
Браузер запам’ятовує останній отриманий ідентифікатор. Під час перепідключення він надсилає його серверу в HTTP-заголовку:
Last-Event-ID: 101Сервер може прочитати цей заголовок у Node.js:
const lastEventIdHeader = request.headers['last-event-id'];
const lastEventId = Number.parseInt(lastEventIdHeader, 10);Після цього сервер повторно надсилає події з більшими ідентифікаторами:
for (const event of history) {
if (event.id > lastEventId) {
sendEvent(response, event);
}
}Браузер передасть повторно отримані події звичайним обробникам. Клієнтський код не повинен окремо розрізняти звичайну та відновлену доставку.
У прикладі історія зберігається в масиві:
const history = [];
const historyLimit = 20;Після кожної події сервер додає її до історії та видаляє найстарішу, якщо ліміт перевищено:
history.push(event);
if (history.length > historyLimit) {
history.shift();
}Це підходить для навчального прикладу або короткочасних оновлень. Якщо клієнт був відключений надовго й потрібної події вже немає в пам’яті, сервер не зможе її повторити.
Для надійного відновлення потрібно зберігати події в довговічному сховищі та визначити політику для ситуації, коли історія вже недоступна. Наприклад, клієнт може отримати поточний стан замість усіх пропущених змін.
Клієнт може закрити SSE-з’єднання вручну:
source.close();Після цього браузер не виконуватиме автоматичних спроб перепідключення.
На сервері потрібно видаляти закриті відповіді з колекції клієнтів:
request.on('close', () => {
clients.delete(response);
});Якщо цього не робити, колекція поступово накопичуватиме неактивні з’єднання, а сервер продовжуватиме намагатися надсилати їм дані.
Довге з’єднання може виглядати неактивним для проксі або балансувальника навантаження. Через це проміжний компонент може закрити його.
SSE підтримує коментарі. Рядок, що починається з двокрапки, не передається обробникам подій:
: heartbeat
Node.js може періодично надсилати такі коментарі:
setInterval(() => {
for (const client of clients) {
if (!client.destroyed) {
client.write(': heartbeat\n\n');
}
}
}, 20000);Heartbeat не є даними для клієнта, але показує мережевому обладнанню, що з’єднання все ще активне.
SSE не працюватиме, якщо сервер повертає звичайний тип:
Content-Type: application/jsonПотрібно використовувати:
Content-Type: text/event-streamБраузер визначає кінець SSE-повідомлення за порожнім рядком. Подія має завершуватися двома символами нового рядка:
response.write(`data: ${JSON.stringify(data)}\n\n`);Один \n не завершує подію.
data у SSE має бути текстом. Об’єкти потрібно перетворювати на JSON:
response.write(`data: ${JSON.stringify({ value: 10 })}\n\n`);На клієнті дані потрібно розібрати:
const data = JSON.parse(event.data);Кожне підключення потрібно видаляти після закриття запиту:
request.on('close', () => {
clients.delete(response);
});Інакше сервер може витрачати пам’ять на вже закриті з’єднання.
EventSource вже має автоматичне перепідключення. Створення нового екземпляра в onerror може призвести до дублювання подій і зайвого навантаження.
Історія в масиві зникає після перезапуску процесу й має обмежений розмір. Для відновлення критично важливих подій потрібне окреме сховище.
SSE передає дані в одному напрямку: від сервера до браузера.
Клієнт створює з’єднання через EventSource.
Сервер надсилає відповідь із типом text/event-stream.
Подія завершується порожнім рядком.
Поле event дає змогу створювати іменовані події.
Поле id допомагає відстежувати останню отриману подію.
Під час перепідключення браузер надсилає серверу Last-Event-ID.
Сервер може повторно надіслати події, пропущені під час розриву.
Heartbeat-коментарі допомагають підтримувати довге з’єднання активним.
Закриті клієнтські з’єднання потрібно видаляти з колекції сервера.