Mundarija (28)
- Bu darsda
- 1. Nega bu kerak?
- 2. text/event-stream formati
- 2.1 Maydonlar
- 2.2 Tahlilchini o'zimiz yozamiz
- 2.3 JSON va yangi qator
- 3. Node'da birinchi SSE javobi
- 3.1 node:http bilan
- 3.2 Sarlavhalar va ularning sababi
- 4. Brauzer tomoni: EventSource
- 5. Qayta ulanish va Last-Event-ID
- 5.1 Oqim
- 5.2 Tarix qancha?
- 6. Production tuzoqlari
- 6.1 Heartbeat
- 6.2 nginx buferlashi
- 6.3 Sekin mijoz
- 6.4 Ulanish limitlari
- 7. Hujumchi nigohi
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): bo'shliqlarni to'ldiring
- 2-mashq (o'rta): ko'p qatorli data
- 3-mashq (qiyin): tarixdagi teshik
- 10. Loyiha qadami: SseHub moduli
- 11. Real ishda
- Xulosa
- Manbalar
Server-Sent Events: Node'da SSE oqimi, Last-Event-ID va heartbeat
Qisqacha: SSE — tugamaydigan oddiy HTTP javob. Server
text/event-streamturidaid:,event:,data:qatorlarini yozadi, bo'sh qator hodisani tugatadi. BrauzerdagiEventSourceuzilsa o'zi qayta ulanadi va oxirgiidniLast-Event-IDsarlavhasida 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-streamformatini qo'lda yozasiz va tahlil qilasiz.- Node'da
node:httpbilan SSE javobini yuborib, unifetchbilan o'qiysiz. Last-Event-IDbilan o'tkazib yuborilgan hodisalarni qayta berasiz.- Heartbeat, nginx buferlashi va sekin mijoz muammolarini hal qilasiz.
- Kanon
SseHubmodulini 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:
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:
{
tur: 'vazifa.yangilandi',
id: '7',
data: '{"id":7,"bajarildi":true}'
}
{ tur: 'message', id: '7', data: 'birinchi qator\nikkinchi qator' }Nimaga qarang:
retry:va: pinghodisa bermadi — biri sozlama, ikkinchisi izoh.- Ikkinchi hodisada
id:yo'q, lekinidhali ham'7'. Standartga ko'ra oxirgiideslab qolinadi — qayta ulanganda aynan u yuboriladi. - Ikki
data:qatori bitta matnga\nbilan qo'shildi. "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: salomdan 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):
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:
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):
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:
event:nomli hodisaonmessagega tushmaydi — faqataddEventListener("nom")bilan. Kanonvazifa.yangilandi,vazifa.bajarildikabi nomlar ishlatadi.EventSourcesarlavha qo'sha olmaydi.Authorization: Bearer …yuborib bo'lmaydi. Auth — cookie bilan (bir origin yokiwithCredentials+ CORS). Bu 28-qismdagi Session-based auth bilan tabiiy ishlaydi.- Faqat GET. Mijoz serverga biror narsa aytmoqchi bo'lsa — oddiy
fetchPOST.
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:
: 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-Typei — - Hodisani tugatadigan narsa —
- Brauzer qayta ulanganda yuboradigan sarlavha —
:bilan boshlangan qator —- nginx buferlashini o'chiradigan javob sarlavhasi —
Yechim
- text/event-stream — boshqa turni
EventSourcerad 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
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:
"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
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:
{ 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):
// 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 —
Setichida ikkitaResponse.yuborikkalasiga yozadi va nechtasiga yetganini qaytaradi (0 — u hozir oflayn; bildirishnoma markazi buni boshqa kanal uchun ishlatishi mumkin, Bildirishnoma tizimi arxitekturasi). lastEventIdquery parametri — zaxira yo'l: o'zingiz yozgan mijoz birinchi ulanishda ham oxirgiidni aytishi uchun (EventSourcesarlavhani 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:
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):
node --test test/sse.test.tsChiqishda avval ikki qatorli ogohlantirishga, keyin ikki ✔ ga qarang:
(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.1682Nima 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):
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: truejavobini aynantext/event-streamformatida 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.stringifybilan: yangi qator qochiriladi, SSE injection bo'lmaydi. EventSourceo'zi qayta ulanadi vaLast-Event-IDyuboradi; server tarixdan o'tkazib yuborilganlarni beradi.- Production:
: pingheartbeat (25 s),X-Accel-Buffering: no,no-transform, sekin mijozni 256 KB da uzish. - Auth — cookie bilan (
EventSourcesarlavha 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
- HTML standarti, Server-sent events (format,
Last-Event-ID, qayta ulanish): https://html.spec.whatwg.org/multipage/server-sent-events.html - MDN, Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- nginx
proxy_buffering,X-Accel-Buffering,proxy_read_timeout: https://nginx.org/en/docs/http/ngx_http_proxy_module.html — sinalmagan, 2026-10-28 da o'qildi - undici
EventSource: https://undici.nodejs.org/#/docs/api/EventSource - Kanon:
SseHub(express 5.3.0, undici 8.12.3, TypeScript 6.0.3, Node 24.21.0); testlarni biz ishga tushirdik (2026-10-28).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!