6. Server-Sent Events (SSE)
SSE — простой механизм однонаправленного потока данных: сервер → клиент. Встроен в браузер, не требует библиотек.
Когда SSE, а не WebSocket
Заголовок раздела «Когда SSE, а не WebSocket»SSE лучше WebSocket когда:✅ Нужен только поток от сервера (уведомления, прогресс, новости)✅ Автоматическое переподключение браузером✅ Работает через обычный HTTP (прокси, CDN friendly)✅ Простота реализации✅ Нативная поддержка кодировки
WebSocket нужен когда:✅ Двусторонняя связь (чат, игры)✅ Бинарные данные эффективно✅ Нужен кастомный контроль соединенияФормат SSE
Заголовок раздела «Формат SSE»SSE — это HTTP-ответ с Content-Type: text/event-stream:
data: {"message": "hello"}\n\n
event: user-joined\ndata: {"username": "Alice"}\n\n
id: 42\ndata: {"count": 42}\n\n
: Это комментарий (игнорируется)\n\n
retry: 3000\ndata: {"reconnect": "delay"}\n\nКаждое событие разделяется двойным \n\n. Поля:
data:— данные (можно несколько строк)event:— имя события (по умолчаниюmessage)id:— ID для Last-Event-IDretry:— задержка переподключения (мс):— комментарий (keepalive)
Сервер — Express
Заголовок раздела «Сервер — Express»const express = require('express');const app = express();
// Хранилище клиентовconst clients = new Map();
app.get('/events', (req, res) => { // SSE заголовки res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Отключить буферизацию в Nginx res.flushHeaders();
const clientId = Date.now(); clients.set(clientId, res);
// Первое сообщение res.write(`data: ${JSON.stringify({ connected: true, id: clientId })}\n\n`);
// Удалить клиента при отключении req.on('close', () => { clients.delete(clientId); console.log(`Клиент ${clientId} отключился`); });});
// Отправить событие всем клиентамfunction broadcast(event, data) { const message = event !== 'message' ? `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` : `data: ${JSON.stringify(data)}\n\n`;
clients.forEach((res) => { res.write(message); });}
// Отправить событие конкретному клиентуfunction sendToClient(clientId, event, data) { const res = clients.get(clientId); if (res) { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }}
// Keepalive — предотвращает таймаут проксиsetInterval(() => { clients.forEach((res) => res.write(': keepalive\n\n'));}, 25000);
// Пример: отправка уведомленийapp.post('/notify', express.json(), (req, res) => { broadcast('notification', req.body); res.json({ sent: clients.size });});
app.listen(3000);Last-Event-ID — восстановление после обрыва
Заголовок раздела «Last-Event-ID — восстановление после обрыва»Браузер автоматически отправляет Last-Event-ID при переподключении:
let eventId = 0;const eventStore = []; // В реальности — БД
app.get('/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders();
// Отправить пропущенные события const lastId = parseInt(req.headers['last-event-id'] || '0'); const missed = eventStore.filter((e) => e.id > lastId);
missed.forEach((e) => { res.write(`id: ${e.id}\ndata: ${JSON.stringify(e.data)}\n\n`); });
clients.set(req.socket, res); req.on('close', () => clients.delete(req.socket));});
function publish(data) { eventId++; const event = { id: eventId, data }; eventStore.push(event);
clients.forEach((res) => { res.write(`id: ${eventId}\ndata: ${JSON.stringify(data)}\n\n`); });}SSE в Next.js (App Router)
Заголовок раздела «SSE в Next.js (App Router)»import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) { const encoder = new TextEncoder();
const stream = new ReadableStream({ start(controller) { // Отправить первые данные controller.enqueue( encoder.encode(`data: ${JSON.stringify({ connected: true })}\n\n`) );
// Подписаться на события const unsubscribe = subscribeToEvents((event) => { controller.enqueue( encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`) ); });
// Keepalive const interval = setInterval(() => { controller.enqueue(encoder.encode(': keepalive\n\n')); }, 25000);
// Закрытие request.signal.addEventListener('abort', () => { unsubscribe(); clearInterval(interval); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, });}Браузерный EventSource API
Заголовок раздела «Браузерный EventSource API»// Подключениеconst eventSource = new EventSource('/api/events');
// Стандартное событие 'message'eventSource.onmessage = (event) => { const data = JSON.parse(event.data); console.log('Данные:', data); console.log('ID:', event.lastEventId);};
// Именованные событияeventSource.addEventListener('notification', (event) => { const notification = JSON.parse(event.data); showNotification(notification);});
eventSource.addEventListener('user-joined', (event) => { const user = JSON.parse(event.data); updateUserList(user);});
// Состояниеconsole.log(eventSource.readyState);// 0 = CONNECTING// 1 = OPEN// 2 = CLOSED
// Ошибки и переподключениеeventSource.onerror = (event) => { if (eventSource.readyState === EventSource.CLOSED) { console.error('Соединение закрыто'); } else { console.warn('Ошибка, переподключаемся...'); // Браузер автоматически переподключится! }};
// Закрыть соединениеeventSource.close();SSE с авторизацией
Заголовок раздела «SSE с авторизацией»EventSource не поддерживает заголовки напрямую! Варианты:
// Вариант 1: Токен в query параметреconst token = localStorage.getItem('token');const eventSource = new EventSource(`/api/events?token=${token}`);
// Сервер проверяет токенapp.get('/api/events', (req, res) => { const token = req.query.token; const user = verifyToken(token); if (!user) { res.status(401).end(); return; } // ... устанавливаем SSE});
// Вариант 2: Сессионные куки (автоматически отправляются!)const eventSource = new EventSource('/api/events', { withCredentials: true, // Включить куки});
// Вариант 3: Использовать fetch вместо EventSourceasync function* streamEvents(url, token) { const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, });
const reader = response.body.getReader(); const decoder = new TextDecoder();
while (true) { const { done, value } = await reader.read(); if (done) break;
const text = decoder.decode(value); const lines = text.split('\n');
for (const line of lines) { if (line.startsWith('data: ')) { yield JSON.parse(line.slice(6)); } } }}
// Использованиеfor await (const event of streamEvents('/api/events', token)) { console.log('Событие:', event);}Задания
Заголовок раздела «Задания»- Создай SSE endpoint для real-time уведомлений. Протестируй через curl
- Реализуй отправку пропущенных событий через
Last-Event-ID - Добавь авторизацию через куки в SSE endpoint
- Сравни нагрузку: SSE vs polling каждую секунду (открой 100 вкладок DevTools)
SSE — элегантное решение для однонаправленных потоков. Проще WebSocket, работает через HTTP/2, автоматически переподключается. В следующем уроке строим полноценный real-time чат.