Az SSE nem „olcsó WebSocket”: a proxy, az újracsatlakozás és a Last-Event-ID

A Server-Sent Events nem csak három sor EventSource-kód: proxybuffer, heartbeat és visszajátszható Last-Event-ID nélkül az „élő” stream könnyen csendes adatvesztéssé válik.

„Küldjünk rá WebSocketet” – hangzik el, amikor egy felületen egy állapotnak magától kell frissülnie. Pedig ha a böngészőnek csak hallgatnia kell – build állapota, rendelés státusza, értesítések, futó import naplója –, a Server-Sent Events (SSE) gyakran kevesebb mozgó alkatrész. Egy hosszú HTTP-válasz, amelynek a szerver új sorokat ír. A csapda nem a három sor klienskód: az újracsatlakozás és a köztes proxy teszi production problémává.

Böngésző, fordított proxy és szerver közötti folyamatos eseményfolyam sematikus ábrája

Ez HTTP, csak nem akar befejeződni

Az EventSource a text/event-stream választ olvassa. Egy üzenet mezőkből áll; az üres sor zárja le és ekkor érkezik meg a böngészőben. Az event: névvel saját eseménytípust adhatunk, a data: sorokból lesz a payload, az id: pedig nem díszlet: ez a visszatérés kulcsa. A formátum részleteit a HTML szabvány SSE-fejezete rögzíti.

A kliensoldal szinte sértően rövid:

const events = new EventSource('/events');

events.addEventListener('build', ({ data, lastEventId }) => {
  const build = JSON.parse(data);
  renderBuild(build, lastEventId);
});

A kapcsolat hibájánál az objektum alapból újranyitja a streamet. A szerver retry: 5000 sorral mondhatja meg, hány ezredmásodperc után próbálkozzon újra. Ez nem megbízható üzenetsor: nincs kliens ACK, nincs tranzakció, nincs pontosan egyszeri kézbesítés. Viszont a szerver tudhatja, honnan kér visszajátszást.

A Last-Event-ID nem varázslat, hanem cursor

Ha egy elküldött blokkban volt id: 42, majd megszakad a kapcsolat, az EventSource a következő kérésben Last-Event-ID: 42 fejlécet küld. A szerver ebből például a 42-nél nagyobb eseményeket olvassa ki egy tartós, sorrendezett naplóból. Na, ezt nem tudtam, amikor először használtam: az id önmagában semmit nem javít; egy memóriában levő tömbbel csak addig „megbízható”, amíg ugyanaz a processz épp nem indult újra.

Ezért az ID legyen valódi resume cursor: adatbázis-sorszám, broker offset vagy más monoton rendezhető érték. A kliensnek az ismétlést el kell viselnie, a szervernek pedig ki kell mondania, mi történik, ha a cursor már kiesett a megőrzési ablakból: teljes állapot-szinkron, nem pedig csendes adatvesztés. Az MDN SSE útmutatója is kiemeli az automatikus reconnectet és a kommentként küldött heartbeatet.

Az alábbi minimális Node-szerver futtatható példa. A flushHeaders() azonnal kiküldi a fejléceket; a Node HTTP dokumentációja szerint erre való, ha a válasz törzse később jön. A : ping SSE-komment: a kliens figyelmen kívül hagyja, a kapcsolatot viszont nem hagyja teljesen néma csőnek.

import { createServer } from 'node:http';

const history = [
  { id: 41, status: 'queued' },
  { id: 42, status: 'running' },
  { id: 43, status: 'finished' },
];

function send(res, message) {
  res.write(`id: ${message.id}\n`);
  res.write('event: build\n');
  res.write(`data: ${JSON.stringify(message)}\n\n`);
}

createServer((req, res) => {
  if (req.url !== '/events') return res.writeHead(404).end();
  const after = Number.parseInt(req.headers['last-event-id'] ?? '0', 10) || 0;

  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();
  res.write(': connected\n\n');
  for (const message of history.filter(({ id }) => id > after)) send(res, message);

  const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
  req.on('close', () => clearInterval(heartbeat));
}).listen(8787);

A reverse proxy is része a protokollnak

Lokálisan működik, Nginx mögött pedig a kliens percenként, csomagban kapja meg az „élő” adatot. Ismerős kis horror. Az Nginx válaszbufferelése erre teljesen racionális alapbeállítás, SSE-hez viszont rossz. Az Nginx saját proxy dokumentációja szerint a proxy_buffering off szinkronosan továbbítja a választ, és az upstream X-Accel-Buffering: no fejléce is vezérelheti ezt. A deploy-konfigurációban ezt, valamint a proxy read timeoutját kifejezetten az eseményútvonalra állítsuk be; a szerver által küldött heartbeat csak ezután tud életben tartani bármit.

Másik kellemetlen részlet: natív EventSource-szal nem állíthatsz tetszőleges Authorization fejlécet. Azonos origón a secure, HTTP-only session cookie kényelmes; keresztorigón a CORS és a credential-beállítások is belépnek a képbe. Tokent query stringbe tenni azért, hogy működjön a demo, a naplóidnak és a böngészőelőzménynek ajándék.

Több szerverpéldánynál a visszajátszásnak is közös helyről kell jönnie. A sticky session itt csak egy kényelmes véletlen: egy deploy, egy podhiba vagy egy böngésző új kapcsolatútja már másik processzhez visz. Mérném az aktív streameket, a reconnectek számát és azt az időt, amely az esemény tartós rögzítése és a kliensoldali feldolgozás között telik el. A nyitott socketek száma önmagában legfeljebb azt bizonyítja, hogy sok a nyitott socket.

HTTP/1.1 esetén számolj az egy origóra jutó alacsony böngészős kapcsolatszámmal: több fül és több stream együtt gyorsan beleáll a plafonba. HTTP/2-n a streamek száma tárgyalt limit, ezért ott sokkal barátságosabb a kép, de a proxy timeoutját ettől még nem oldja meg a varázslómanó.

Mikor választanám?

Productionben használnám egyirányú státuszokhoz, naplókhoz és értesítésekhez, ahol a kliens a normál HTTP API-n indítja a műveletet, az SSE-n pedig követi. Előtte megtervezném a visszajátszási ablakot, az idempotens kliensfrissítést, a heartbeatet és proxyval mért viselkedést. Nem ezt választanám chathez, közös szerkesztéshez vagy gyakori kliens–szerver parancsokhoz: ott a WebSocket vagy egy kétirányú protokoll őszintébb. Az SSE ereje épp az, hogy nem próbál többnek látszani egy gondosan befejezetlen HTTP-válasznál.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük