IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime38/69-dars18 daqiqa
Mundarija (28)

Server-Sent Events: Node'da SSE oqimi, Last-Event-ID va heartbeat

Qisqacha: SSE — tugamaydigan oddiy HTTP javob. Server text/event-stream turida id:, event:, data: qatorlarini yozadi, bo'sh qator hodisani tugatadi. Brauzerdagi EventSource uzilsa o'zi qayta ulanadi va oxirgi id ni Last-Event-ID sarlavhasida yuboradi — server o'tkazib yuborilganlarni qayta beradi. Productionda yana uch narsa kerak: heartbeat izohi (proksi jim ulanishni uzmasin), X-Accel-Buffering: no (nginx buferlamasin) va sekin mijozni uzish.

Bu darsda

  • text/event-stream formatini qo'lda yozasiz va tahlil qilasiz.
  • Node'da node:http bilan SSE javobini yuborib, uni fetch bilan o'qiysiz.
  • Last-Event-ID bilan o'tkazib yuborilgan hodisalarni qayta berasiz.
  • Heartbeat, nginx buferlashi va sekin mijoz muammolarini hal qilasiz.
  • Kanon SseHub modulini o'qiysiz va uning testlarini ishga tushirasiz.

Oldin bilishingiz kerak: Realtime usullari va tanlov, WebSocket va Server-Sent Events (EventSource brauzerda), res obyekti, Backpressure, Reverse proxy orqasida Node.

Oldingi darsda kanon vazifalar uchun SSE'ni tanladi: server gapiradi, mijoz tinglaydi. Bugun shu oqimni server tomonda quramiz.

1. Nega bu kerak?

Jasur aka telefonida "Hisobotni topshirish" vazifasini bajarildi deb belgiladi. Noutbukdagi oyna buni bir soniya ichida ko'rsatishi kerak. Oyna ochilganda bitta so'rov yuboradi va javobni o'qishda davom etadi — server yangilik chiqqanda shu javobga bir necha qator qo'shadi.

O'xshatish: radio. Qabul qilgich bir marta to'lqinga sozlanadi, keyin stansiya gapirganini eshitib turadi. Signal uzilsa — qabul qilgich o'zi qayta qidiradi. Lekin radio "men eshitmay qolgan yangiliklarni qaytar" deya olmaydi. SSE esa aytadi: Last-Event-ID aynan shu.

2. text/event-stream formati

2.1 Maydonlar

SSE javobi — oddiy UTF-8 matn, qatorlarga bo'lingan. Har qator maydon: qiymat shaklida:

Maydon Nima qiladi
data: hodisa mazmuni; bir necha data: qatori \n bilan birlashadi
event: hodisa nomi; yo'q bo'lsa — message
id: hodisa raqami; brauzer eslab qoladi
retry: uzilsa necha ms dan keyin qayta ulanish
: bilan boshlangan izoh — mijoz e'tiborsiz qoldiradi

Bo'sh qator — hodisaning oxiri. Usiz brauzer hodisani hali tugamagan deb kutadi.

2.2 Tahlilchini o'zimiz yozamiz

Brauzerdagi EventSource oqimni HTML standartidagi qoidalar bo'yicha o'qiydi. Shu qoidalarning asosiy qismini yozib ko'ramiz — format shunda aniq ko'rinadi:

js
function sseTahlil(matn) {
  const hodisalar = [];
  let tur = "message";
  let malumot = [];
  let oxirgiId = "";
  for (const qator of matn.split(/\r\n|\r|\n/)) {
    if (qator === "") { // bo'sh qator — hodisa tugadi
      if (malumot.length > 0) {
        const data = malumot.join("\n");
        hodisalar.push({ tur, id: oxirgiId, data });
      }
      tur = "message";
      malumot = [];
      continue;
    }
    if (qator.startsWith(":")) continue; // izoh (heartbeat)
    const i = qator.indexOf(":");
    const maydon = i === -1 ? qator : qator.slice(0, i);
    let qiymat = i === -1 ? "" : qator.slice(i + 1);
    if (qiymat.startsWith(" ")) qiymat = qiymat.slice(1);
    if (maydon === "data") malumot.push(qiymat);
    else if (maydon === "event") tur = qiymat;
    else if (maydon === "id") oxirgiId = qiymat;
  }
  return hodisalar;
}

const oqim = [
  "retry: 3000",
  "",
  ": ping",
  "",
  "id: 7",
  "event: vazifa.yangilandi",
  'data: {"id":7,"bajarildi":true}',
  "",
  "data: birinchi qator",
  "data: ikkinchi qator",
  "",
  "data: yarim qolgan",
].join("\n");

for (const h of sseTahlil(oqim)) console.log(h);

Konsolda:

text
{
  tur: 'vazifa.yangilandi',
  id: '7',
  data: '{"id":7,"bajarildi":true}'
}
{ tur: 'message', id: '7', data: 'birinchi qator\nikkinchi qator' }

Nimaga qarang:

  1. retry: va : ping hodisa bermadi — biri sozlama, ikkinchisi izoh.
  2. Ikkinchi hodisada id: yo'q, lekin id hali ham '7'. Standartga ko'ra oxirgi id eslab qolinadi — qayta ulanganda aynan u yuboriladi.
  3. Ikki data: qatori bitta matnga \n bilan qo'shildi.
  4. "yarim qolgan" chiqmadi: undan keyin bo'sh qator yo'q. Brauzer ham shunday qiladi — tugamagan hodisa tashlanadi.

Bu tahlilchi o'quv uchun. U retry: ni o'qimaydi. Matn tarmoqdan bo'lak-bo'lak keladi va qator yarmida uzilishi mumkin — tahlilchi buni ham hisobga olmaydi. Brauzerdagi EventSource bularni o'zi hal qiladi.

2.3 JSON va yangi qator

data: ichida haqiqiy yangi qator bo'lsa, u yangi maydon bo'lib qoladi. Shuning uchun ma'lumotni JSON.stringify bilan yozing: u matndagi yangi qatorni ikki belgili \n ga aylantiradi va hodisa bitta data: qatorida qoladi. Kanon modul aynan shunday qiladi.

Tekshirib ko'ring: Server data: salom dan keyin bo'sh qator yozishni unutdi va keyingi hodisani 10 soniyadan keyin yubordi. Mijoz nimani ko'radi?

Javob

10 soniya hech narsa. Bo'sh qator kelmaguncha brauzer hodisani tugamagan deb kutadi. Keyingi hodisa data: … qatorlari bilan kelsa, ular birinchisiga qo'shilib ketadi va bitta hodisa bo'lib chiqadi ("salom\n…"). Har hodisa \n\n bilan tugashi shart.

3. Node'da birinchi SSE javobi

3.1 node:http bilan

Express shart emas — SSE oddiy HTTP javob. Server sarlavhalarni yuboradi, uch bo'lak yozadi va (namuna uchun) javobni yopadi. Mijoz — Node'ning o'rnatilgan fetch (Built-in fetch va undici):

js
import http from "node:http";
import { once } from "node:events";

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream; charset=utf-8",
    "Cache-Control": "no-cache, no-transform",
  });
  res.write("retry: 3000\n\n");
  res.write(": ping\n\n");
  const malumot = JSON.stringify({ id: 7, matn: "satr1\nsatr2" });
  res.write(`id: 1\nevent: vazifa.yangilandi\ndata: ${malumot}\n\n`);
  res.end(); // namuna uchun oqimni yopamiz
});
server.listen(0, "127.0.0.1");
await once(server, "listening");

const { port } = server.address();
const javob = await fetch(`http://127.0.0.1:${port}/sse`);
console.log(javob.status, javob.headers.get("content-type"));
const matn = await javob.text();
for (const qator of matn.split("\n")) {
  console.log(qator === "" ? "(bo'sh qator)" : qator);
}
server.close();

Konsolda birinchi qatordagi status va turga, keyin data: qatoridagi \n ga qarang:

text
200 text/event-stream; charset=utf-8
retry: 3000
(bo'sh qator)
: ping
(bo'sh qator)
id: 1
event: vazifa.yangilandi
data: {"id":7,"matn":"satr1\nsatr2"}
(bo'sh qator)
(bo'sh qator)

Bu blok faqat Node'da ishlaydi (port ochadi) — saytda «Ishga tushir» tugmasi yo'q, o'z terminalingizda node fayl.mjs bilan yurgizing. Oxirgi ikki "bo'sh qator" — \n\n ni split qilganda chiqadi. satr1\nsatr2 bitta data: qatorida qoldi — JSON.stringify ishi.

Haqiqiy SSE'da res.end() yo'q: javob mijoz ketguncha ochiq. Shuning uchun text() bilan to'liq o'qib bo'lmaydi — oqimni bo'lak-bo'lak o'qiysiz (res.body.getReader(), Async iteratorlar va Web Streams). Kanon testlari aynan shunday qiladi.

3.2 Sarlavhalar va ularning sababi

Sarlavha Nega
Content-Type: text/event-stream EventSource boshqa turni rad etadi
Cache-Control: no-cache, no-transform kesh saqlamasin, proksi siqmasin
Connection: keep-alive HTTP/1.1 da ulanish ochiq qolsin
X-Accel-Buffering: no nginx shu javobni buferlamasin

no-transform muhim: ba'zi proksilar javobni gzip qilish uchun to'plab oladi — hodisalar bir necha soniya kechikib, birdaniga keladi. Express'dagi compression middleware (Mashhur middleware'lar) ham SSE marshrutida shunday qilishi mumkin — u yerda uni o'chiring.

res.flushHeaders() — sarlavhalarni darhol yuboradi. Usiz Node ularni birinchi write gacha ushlab turadi va brauzerdagi onopen kechikadi.

4. Brauzer tomoni: EventSource

Mijoz kodi qisqa (to'liq — WebSocket va Server-Sent Events darsida):

js
const es = new EventSource("/api/oqim", { withCredentials: true });
es.addEventListener("vazifa.yangilandi", (e) => {
  const vazifa = JSON.parse(e.data);
  yangila(vazifa);
});
es.onerror = () => {
  // brauzer o'zi qayta ulanadi; readyState: 0 — ulanmoqda
  holat.textContent = "Qayta ulanmoqda…";
};

Server uchun muhim uch fakt:

  1. event: nomli hodisa onmessage ga tushmaydi — faqat addEventListener("nom") bilan. Kanon vazifa.yangilandi, vazifa.bajarildi kabi nomlar ishlatadi.
  2. EventSource sarlavha qo'sha olmaydi. Authorization: Bearer … yuborib bo'lmaydi. Auth — cookie bilan (bir origin yoki withCredentials + CORS). Bu 28-qismdagi Session-based auth bilan tabiiy ishlaydi.
  3. Faqat GET. Mijoz serverga biror narsa aytmoqchi bo'lsa — oddiy fetch POST.

fetch + getReader() bilan o'zingiz o'qisangiz, sarlavha qo'sha olasiz — AI chat kutubxonalari shunday qiladi. Lekin unda qayta ulanish va Last-Event-ID ni o'zingiz yozasiz.

5. Qayta ulanish va Last-Event-ID

5.1 Oqim

sequenceDiagram
  participant B as Brauzer
  participant S as Server
  B->>S: GET /oqim
  S-->>B: retry 3000, id 41, id 42
  Note over B: Wi-Fi uzildi
  Note over S: id 43, 44 tarixga yozildi
  B->>S: GET /oqim, Last-Event-ID 42
  S-->>B: id 43, id 44 (tarixdan)
  S-->>B: id 45 (yangi)

Diagrammaga qarang: uzilish paytida server hodisalarni yo'qotmaydi — ularni foydalanuvchi tarixiga yozadi. Brauzer 3 soniyadan keyin (retry: 3000) qayta ulanadi va oxirgi ko'rgan id ni sarlavhada aytadi. Server tarixdan 42 dan kattalarini yuboradi, keyin odatdagidek davom etadi.

5.2 Tarix qancha?

Kanon har foydalanuvchiga oxirgi 100 ta hodisani xotirada saqlaydi. Ikki savol qoladi:

  • Mijoz juda uzoq yo'q edi (100 tadan ko'p hodisa o'tdi). Tarixda teshik bor — to'liq ro'yxatni qayta yuklash kerak. 3-mashqda buni aniqlashni yozasiz.
  • Server qayta ishga tushdi yoki API ikki nusxada ishlaydi. Xotiradagi tarix yo'qoladi yoki boshqa nusxada. Yechim — tarixni Redis Stream'da saqlash (Pub/Sub va Redis Streams); batafsil — Realtime'ni masshtablash darsida.

id lar foydalanuvchi bo'yicha emas, butun hub bo'yicha o'sadi (#keyingiId) — ikki foydalanuvchining hodisalari bir xil raqam olmaydi.

6. Production tuzoqlari

6.1 Heartbeat

Uzoq vaqt hech narsa yozilmasa, oradagi proksi ulanishni "o'lik" deb uzadi. nginx'da proxy_read_timeout sukut bo'yicha 60 soniya. Kanon har 25 soniyada izoh yozadi:

text
: ping

Izoh qatori EventSource da hodisa chiqarmaydi, lekin baytlar oqadi va proksi kutishni qaytadan boshlaydi. Bu heartbeat (yurak urishi) — "men tirikman" signali.

6.2 nginx buferlashi

nginx sukut bo'yicha backend javobini buferga yig'adi va bo'lak-bo'lak yuboradi. SSE uchun bu halokat: hodisalar kechikadi. Ikki yo'l bor: marshrut sozlamasida proxy_buffering off; yoki javobda X-Accel-Buffering: no sarlavhasi. Kanon ikkinchisini tanladi — kod o'zi aytadi, server sozlamasini unutib qo'yish xavfi yo'q. nginx bilan haqiqiy tekshiruv kanonda sinalmagan (33-qismda serverda).

6.3 Sekin mijoz

Mijoz ma'lumotni olishga ulgurmasa (sekin internet, uxlab qolgan noutbuk), Node yozilgan baytlarni xotirada ushlaydi: res.writableLength o'sadi (Backpressure). Minglab bunday mijoz — server xotirasi tugaydi.

Kanon qarori sodda: bufer 256 KB dan oshsa — ulanishni uzadi (r.destroy()). Mijoz qayta ulanadi va Last-Event-ID bilan tarixdan yetishib oladi. Ma'lumot yo'qolmaydi, xotira esa cheklangan.

6.4 Ulanish limitlari

Oldingi darsda kanon Chromium'da bitta origin'ga 7 ta EventSource ochdi — 6 tasi ochildi. Foydalanuvchi ko'p tab ochsa, HTTP/2 yoki tablar o'rtasida bitta ulanish kerak. Server tomonda esa har ochiq SSE — ochiq TCP ulanish va fayl deskriptori. Bitta foydalanuvchiga ruxsat etilgan oynalar sonini cheklash — DoS himoyasining bir qismi (DoS va resurs tugashidan himoya).

7. Hujumchi nigohi

Hujum Himoya
begona foydalanuvchi oqimiga ulanish (/oqim?f=7) foydalanuvchi ID si URL'dan emas, sessiyadan
boshqa saytdan EventSource(..., { withCredentials }) CORS: faqat o'z origin'ingiz
minglab ochiq oqim bilan xotirani to'ldirish foydalanuvchiga oyna limiti, rate limit
data: dagi matnni innerHTML ga qo'yish textContent (XSS)
\n qo'shib soxta event: qatori yasash JSON.stringify — yangi qator qochiriladi

Birinchi qator eng ko'p uchraydi. Kanon testida marshrut /sse/:f — foydalanuvchi ID si URL'da. Bu faqat test uchun: auth hali yo'q. 28-qismdan keyin fId tizimga kirgan foydalanuvchidan olinadi.

Oxirgi qator — "SSE injection". Server foydalanuvchi matnini to'g'ridan data: ${matn} qilib yozsa, matndagi \nevent: chiqish\ndata: … yangi hodisa bo'lib chiqadi. JSON.stringify yangi qatorni \n belgilariga aylantirgani uchun bunday matn bitta data: qatorida qoladi.

8. Ko'p uchraydigan xatolar

Xato Belgisi To'g'risi
hodisa oxirida bo'sh qator yo'q mijoz hech narsa olmaydi har hodisa \n\n bilan
flushHeaders() yo'q onopen kechikadi sarlavhalardan keyin darhol
compression SSE'da yoqilgan hodisalar to'planib keladi SSE marshrutida o'chirish
nginx buferlash soniyalab kechikish X-Accel-Buffering: no
heartbeat yo'q ~60 s da uzilish 25 s da : ping
event: nomli hodisani onmessage da kutish hech narsa kelmaydi addEventListener("nom")
close da ulanishni o'chirmaslik xotira sizishi req.on("close")

9. Mashqlar

Mashqlar kurs/mashqlar/29/38-sse/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • SSE javobining Content-Type i —
  • Hodisani tugatadigan narsa —
  • Brauzer qayta ulanganda yuboradigan sarlavha —
  • : bilan boshlangan qator —
  • nginx buferlashini o'chiradigan javob sarlavhasi —
Yechim
  • text/event-stream — boshqa turni EventSource rad etadi.
  • bo'sh qator — \n\n; usiz hodisa tugamagan hisoblanadi.
  • Last-Event-ID — oxirgi id: qiymati.
  • izoh — heartbeat uchun ishlatiladi, hodisa chiqarmaydi.
  • X-Accel-Buffering — qiymati no.

2-mashq (o'rta): ko'p qatorli data

hodisaFormatla({ id, tur, data }) funksiyasini yozing: id va tur bo'lsa tegishli qatorlarni qo'shsin, data dagi har qatorni (\n, \r\n yoki \r bilan ajratilgan) alohida data: qatoriga yozsin, oxirida bo'sh qator. Natijani JSON.stringify bilan chiqaring — yangi qatorlar ko'rinsin. Ikki holat: { id: 3, tur: "izoh", data: "Salom" } va { data: "1-qator\n2-qator\r\n3-qator" }. Ishora: split regex bilan ishlaydi (RegExp asoslari).

Yechim
js
function hodisaFormatla({ id, tur, data }) {
  let matn = "";
  if (id !== undefined) matn += `id: ${id}\n`;
  if (tur) matn += `event: ${tur}\n`;
  for (const qator of String(data).split(/\r\n|\r|\n/)) {
    matn += `data: ${qator}\n`;
  }
  return matn + "\n";
}

const bir = hodisaFormatla({ id: 3, tur: "izoh", data: "Salom" });
const ikki = hodisaFormatla({ data: "1-qator\n2-qator\r\n3-qator" });
console.log(JSON.stringify(bir));
console.log(JSON.stringify(ikki));

Konsolda:

text
"id: 3\nevent: izoh\ndata: Salom\n\n"
"data: 1-qator\ndata: 2-qator\ndata: 3-qator\n\n"

Brauzer ikkinchi hodisani yana "1-qator\n2-qator\n3-qator" qilib yig'adi (\r\n → \n). Bu JSON'siz ko'p qatorli matn yuborishning to'g'ri yo'li. JSON yuborsangiz, JSON.stringify yetarli — natija doim bitta qator.

3-mashq (qiyin): tarixdagi teshik

Tarix klassini yozing (Private # maydon va metodlar): sigim ta oxirgi hodisani saqlaydi (eskisi shift bilan chiqadi), qosh(tur, malumot) yangi id qaytaradi. keyin(oxirgiId) qaytarsin: idlar — oxirgiId dan keyingi saqlangan hodisalar, toliqmi — o'tkazib yuborilganlarning hammasi tarixda bormi. Sig'im 3, 5 ta hodisa qo'shing va keyin(4), keyin(2), keyin(1), keyin(5) ni chiqaring.

Yechim
js
class Tarix {
  #hodisalar = [];
  #sigim;
  #keyingiId = 1;

  constructor(sigim) {
    this.#sigim = sigim;
  }

  qosh(tur, malumot) {
    const h = { id: this.#keyingiId++, tur, malumot };
    this.#hodisalar.push(h);
    if (this.#hodisalar.length > this.#sigim) this.#hodisalar.shift();
    return h.id;
  }

  keyin(oxirgiId) {
    const eng = this.#hodisalar[0]?.id ?? this.#keyingiId;
    // oxirgiId dan keyingi hodisa allaqachon o'chgan bo'lsa — teshik
    const toliqmi = oxirgiId + 1 >= eng;
    const hodisalar = this.#hodisalar.filter((h) => h.id > oxirgiId);
    return { toliqmi, idlar: hodisalar.map((h) => h.id) };
  }
}

const t = new Tarix(3);
for (let i = 0; i < 5; i++) t.qosh("vazifa.yangilandi", { i });
console.log(t.keyin(4));
console.log(t.keyin(2));
console.log(t.keyin(1));
console.log(t.keyin(5));

Konsolda:

text
{ toliqmi: true, idlar: [ 5 ] }
{ toliqmi: true, idlar: [ 3, 4, 5 ] }
{ toliqmi: false, idlar: [ 3, 4, 5 ] }
{ toliqmi: true, idlar: [] }

Tarixda 3, 4, 5 qoldi. keyin(1) da 2-hodisa allaqachon chiqib ketgan — toliqmi: false. Kanon modul bunday holatda baribir borini beradi. Production'da serverning yaxshiroq javobi — maxsus hodisa (masalan event: qayta-yuklash): mijoz ro'yxatni REST orqali to'liq qayta oladi. Kanonda bu yo'q — o'zingiz qo'shishingiz mumkin bo'lgan joy.

10. Loyiha qadami: SseHub moduli

Kanon SSE'ni modullar/src/realtime/sse.ts moduliga yig'di. U Express'ga bog'liq (Request, Response turlari), lekin vazifalar-api dan mustaqil. Telefon uchun uzun qatorlar va izohlarni bo'ldik, kod ma'nosi kanondagidek (formatlangan nusxani tsc, eslint strictTypeChecked va testlar bilan tekshirdik):

ts
// sse.ts — Server-Sent Events hub: foydalanuvchi bo'yicha
// ulanishlar, `id:` + `Last-Event-ID` bilan qayta ulanganda
// o'tkazib yuborilganlarni berish, heartbeat, sekin mijozni uzish
// (backpressure), nginx buferlashini o'chirish. Bir nechta API
// nusxasi — `tarqat` ni Redis Pub/Sub orqali chaqirish (keyinroq).
import type { Request, Response } from "express";

export type Hodisa = { id: number; tur: string; malumot: unknown };

export class SseHub {
  readonly #ulanishlar = new Map<number, Set<Response>>();
  // har foydalanuvchiga oxirgi N hodisa (qayta ulanish uchun);
  // ko'p nusxada — Redis Stream
  readonly #tarix = new Map<number, Hodisa[]>();
  readonly #tarixHajmi: number;
  readonly #maksBufer: number;
  readonly #heartbeat: NodeJS.Timeout;
  #keyingiId = 1;

  constructor(
    o: {
      tarixHajmi?: number;
      heartbeatMs?: number;
      maksBuferBayt?: number;
    } = {},
  ) {
    this.#tarixHajmi = o.tarixHajmi ?? 100;
    this.#maksBufer = o.maksBuferBayt ?? 256 * 1024;
    // 25 s: proxy/brauzer bo'sh ulanishni uzmasin (nginx
    // proxy_read_timeout sukut 60 s); izoh qatori — mijozda hodisa
    // chiqarmaydi
    this.#heartbeat = setInterval(() => {
      for (const s of this.#ulanishlar.values()) {
        for (const r of s) this.#yoz(r, ": ping\n\n");
      }
    }, o.heartbeatMs ?? 25_000);
    this.#heartbeat.unref();
  }

  get ulanishlarSoni(): number {
    let n = 0;
    for (const s of this.#ulanishlar.values()) n += s.size;
    return n;
  }

  /** Express handler ichida: `hub.ulan(req, res, foydalanuvchiId)`
   * (auth oldin — cookie/sessiya; EventSource sarlavha yubora
   * olmaydi). */
  ulan(req: Request, res: Response, fId: number): void {
    res.status(200).set({
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // nginx: shu javobni buferlamasin
    });
    res.flushHeaders();
    res.write("retry: 3000\n\n"); // mijoz uzilsa 3 s da qayta ulanadi
    const qId = req.query["lastEventId"];
    const oxirgi = Number(
      req.get("last-event-id") ?? (typeof qId === "string" ? qId : 0),
    );
    if (oxirgi > 0) {
      for (const h of this.#tarix.get(fId) ?? []) {
        if (h.id > oxirgi) this.#yoz(res, this.#format(h));
      }
    }
    const s = this.#ulanishlar.get(fId) ?? new Set<Response>();
    s.add(res);
    this.#ulanishlar.set(fId, s);
    req.on("close", () => {
      s.delete(res);
      if (s.size === 0) this.#ulanishlar.delete(fId);
    });
  }

  /** Foydalanuvchining hamma ochiq oynalariga yuboradi.
   * Qaytaradi: nechta ulanishga yozildi. */
  yubor(fId: number, tur: string, malumot: unknown): number {
    const h: Hodisa = { id: this.#keyingiId++, tur, malumot };
    const t = this.#tarix.get(fId) ?? [];
    t.push(h);
    if (t.length > this.#tarixHajmi) t.shift();
    this.#tarix.set(fId, t);
    let n = 0;
    for (const r of this.#ulanishlar.get(fId) ?? []) {
      if (this.#yoz(r, this.#format(h))) n++;
    }
    return n;
  }

  #format(h: Hodisa): string {
    // `data:` ichida yangi qator bo'lsa — har qator alohida `data:`
    // (protokol); JSON.stringify yangi qatorni \n ga aylantiradi —
    // xavfsiz
    const data = JSON.stringify(h.malumot);
    return `id: ${String(h.id)}\nevent: ${h.tur}\ndata: ${data}\n\n`;
  }

  /** Sekin mijoz: yozuv buferi chegaradan oshsa — uzamiz (u qayta
   * ulanib Last-Event-ID bilan yetishib oladi). */
  #yoz(r: Response, matn: string): boolean {
    if (r.writableEnded || r.destroyed) return false;
    if (r.writableLength > this.#maksBufer) {
      r.destroy();
      return false;
    }
    r.write(matn);
    return true;
  }

  yop(): void {
    clearInterval(this.#heartbeat);
    for (const s of this.#ulanishlar.values()) {
      for (const r of s) r.end();
    }
    this.#ulanishlar.clear();
  }
}

Uch narsaga e'tibor bering:

  • Bitta foydalanuvchi — ko'p oyna. Jasur akaning telefoni va noutbuki — Set ichida ikkita Response. yubor ikkalasiga yozadi va nechtasiga yetganini qaytaradi (0 — u hozir oflayn; bildirishnoma markazi buni boshqa kanal uchun ishlatishi mumkin, Bildirishnoma tizimi arxitekturasi).
  • lastEventId query parametri — zaxira yo'l: o'zingiz yozgan mijoz birinchi ulanishda ham oxirgi id ni aytishi uchun (EventSource sarlavhani faqat qayta ulanishda qo'yadi).
  • unref() — heartbeat taymeri jarayonni tirik ushlab turmasin (testlar tugagach Node chiqib keta oladi, timers chuqur).

Testlar — modullar/test/sse.test.ts, ikkita. Ikkinchisidan parcha (formatlangan): uch EventSource — foydalanuvchi 2 ning ikki oynasi va begona foydalanuvchi 3:

ts
const a1 = new EventSource(`${url}/sse/2`);
const a2 = new EventSource(`${url}/sse/2`);
const b = new EventSource(`${url}/sse/3`);
// ... har biriga "vazifa.bajarildi" tinglovchisi
assert.equal(hub.yubor(2, "vazifa.bajarildi", { id: 1 }), 2);
assert.deepEqual(kelgan.sort(), ['a1:{"id":1}', 'a2:{"id":1}']);
a1.close(); a2.close(); b.close();
// oflayn paytda 3 ta hodisa
assert.equal(hub.yubor(2, "vazifa.bajarildi", { id: 2 }), 0);
hub.yubor(2, "vazifa.bajarildi", { id: 3 });
hub.yubor(2, "vazifa.bajarildi", { id: 4 });
const r = await fetch(`${url}/sse/2`, {
  headers: { "Last-Event-ID": "2" }, signal: ac.signal,
});
// ... oqimdan uchta data: {"id":2}, {"id":3}, {"id":4}

b hech narsa olmadi — u boshqa foydalanuvchi. Oflayn paytdagi uchta hodisa Last-Event-ID: 2 bilan qaytdi. Nega 2? Hub id lari: birinchi testdagi bitta hodisa (1) va bu testdagi birinchisi (2) — ikkinchi ulanish 2 dan keyingilarni so'radi. Testda EventSource Node uchun undici 8.12.3 dan olinadi. Testlarni biz ishga tushirdik (kanon nusxasi, Node 24.21.0):

bash
node --test test/sse.test.ts

Chiqishda avval ikki qatorli ogohlantirishga, keyin ikki ✔ ga qarang:

text
(node:21116) [UNDICI-ES] Warning: EventSource is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
▶ SSE hub
  ✔ sarlavhalar va xom format (id, event, data, retry, heartbeat) (127.1531ms)
  ✔ EventSource: faqat o'z foydalanuvchisiga; bir foydalanuvchining 2 oynasi; Last-Event-ID bilan o'tkazib yuborilganlar (71.0531ms)
✔ SSE hub (216.44ms)
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10442.1682

Nima bo'ldi? Ikkala test o'tdi: xom format to'g'ri, begona foydalanuvchi hech narsa olmadi, oflayn paytdagi hodisalar qayta ulanishda qaytdi. Birinchi qator — ogohlantirish: undici'dagi EventSource Node'da hali "experimental". Brauzerda esa EventSource barqaror standart API. Millisekundlar va jarayon raqami (21116) har safar boshqacha. Butun modullar paketi kanonda npm run check dan o'tgan: tsc 0, eslint 0, testlar 82/82.

Commit xabari (modul uchun):

bash
git commit -m "feat(realtime): SseHub — Last-Event-ID va heartbeat"

vazifalar-api ga ulash 28-qism (auth) tugagach bo'ladi: fId URL'dan emas, sessiya cookie'sidan olinadi.

11. Real ishda

  • AI API'lar (OpenAI, Anthropic) stream: true javobini aynan text/event-stream formatida beradi — siz bugun yozgan tahlilchi ularni ham o'qiydi. Kursda keyinroq: LLM javoblarini streaming qilish.
  • NestJS da @Sse() dekoratori shu protokolni beradi — 30-qismda.
  • GitHub, Vercel loglarni brauzerga jonli oqizishda SSE ishlatadi.
  • Intervyuda: "SSE va WebSocket farqi?", "SSE'da auth qanday?", "nginx orqasida SSE nega kechikadi?"

Xulosa

  • SSE — tugamaydigan HTTP javob; data:, event:, id:, retry: qatorlari, bo'sh qator hodisani tugatadi.
  • Ma'lumot — JSON.stringify bilan: yangi qator qochiriladi, SSE injection bo'lmaydi.
  • EventSource o'zi qayta ulanadi va Last-Event-ID yuboradi; server tarixdan o'tkazib yuborilganlarni beradi.
  • Production: : ping heartbeat (25 s), X-Accel-Buffering: no, no-transform, sekin mijozni 256 KB da uzish.
  • Auth — cookie bilan (EventSource sarlavha qo'ymaydi); foydalanuvchi ID si sessiyadan, URL'dan emas.
  • Kanon SseHub: foydalanuvchi bo'yicha ko'p oyna, tarix 100 ta, testlar 2/2.

Keyingi dars: WebSocket protokoli va ws — HTTP Upgrade'ni xom baytlarda ko'ramiz, kadrni qo'lda yasaymiz va ws serverida auth, ping/pong va backpressure'ni sinaymiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!