یک نشانگر بارگذاری (loading spinner) هیچ اطلاعاتی به شما نمی‌دهد. وقتی یک وظیفه هوش مصنوعی چندین دقیقه طول می‌کشد — یا برای سومین بار به صف بازگشت می‌کند — شما نیاز دارید که وضعیت را مشاهده کنید. رویدادهای ارسال‌شده از سمت سرور (Server-Sent Events یا SSE) این قابلیت مشاهده را بدون سربار دست‌دادن (handshake) در WebSockets یا پیچیدگی‌های long polling فراهم می‌کنند. سرور یک پاسخ HTTP واحد را باز نگه می‌دارد و با تغییر شرایط، به‌روزرسانی‌های متنی ساده را ارسال (push) می‌کند. کلاینت آن‌ها را به محض رسیدن می‌خواند.

اگر اتصال قطع شود، احتمالاً نمی‌خواهید همه چیز را از ابتدا شروع کنید. یک جریان SSE که به خوبی ساخته شده باشد، می‌داند کجا بوده‌اید. تنها با استفاده از Node.js 20 و کتابخانه استاندارد، می‌توانید این سیستم را راه‌اندازی کنید. هیچ بسته خارجی مورد نیاز نیست.

ساختار پروتکل (wire format) چگونه است

یک پیام SSE متن ساده است. سرور سه مورد را می‌نویسد: یک نام رویداد اختیاری، یک فیلد data الزامی، و یک فیلد id که به عنوان نقطه ذخیره (save point) شما عمل می‌کند. هر رکورد با دو کاراکتر خط جدید (newline) تمام می‌شود — یک خط خالی که مرز را مشخص می‌کند.

یک جریان سالم ممکن است در پروتکل به این شکل باشد:

id: 14
event: status
data: {"phase":"testing","progress":43}

id: 15
event: status
data: {"phase":"retrying","attempt":2}

کلاینت EventSource در مرورگر این خطوط را به‌طور خودکار می‌خواند. برای هر بلوک یک رویداد ایجاد کرده و آخرین id را در حافظه داخلی ذخیره می‌کند. اگر اتصال TCP ناپایدار شود، کلاینت منتظر می‌ماند، دوباره متصل می‌شود و شناسه ذخیره‌شده را از طریق هدر Last-Event-ID به سرور می‌فرستد. این هدر تمام دلیلی است که این الگو کار می‌کند. بدون آن، شما هیچ نشانگر (cursor) پایداری نخواهید داشت.

راه‌اندازی سرور در Node.js

ماژول داخلی http در Node می‌تواند مستقیماً این کار را انجام دهد. وقتی درخواستی دریافت می‌شود، هدرهای صحیح را تنظیم کنید تا کلاینت بداند این یک جریان (stream) است، نه یک صفحه وب:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

بافرینگ (buffering) را حذف کنید. پروکسی‌ها و فریم‌ورک‌ها گاهی اوقات پاسخ‌ها را دسته‌بندی (batch) می‌کنند که حس بی‌درنگ (real-time) بودن را از بین می‌برد، بنابراین پس از هر تکه (chunk)، داده‌ها را فلاش (flush) کنید.

ابتدا ID، سپس نوع رویداد، سپس داده‌های محتوا (payload) و در نهایت خط خالی پایانی را ارسال کنید. ترتیب فقط از این جهت اهمیت دارد که ID باید قبل از خط خالی برسد تا کلاینت بتواند آن را ثبت کند. اگر از response.write() بومی استفاده می‌کنید، خروجی دقیقاً به این صورت است:

response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);

آن \n\n انتهایی تزئینی نیست. تجزیه‌کننده‌های (parsers) SSE آن را به عنوان پایان‌دهنده رکورد در نظر می‌گیرند. اگر آن را فراموش کنید، کلاینت در انتظار داده‌های بیشتر معلق می‌ماند.

نشانگر (cursor) همه چیز است

یک اتصال HTTP جدید، وضعیت (state) جدید را تضمین نمی‌کند. وقتی کلاینت دوباره متصل می‌شود، هدر Last-Event-ID آخرین پیامی را که دریافت کرده به شما می‌گوید. وظیفه شما این است که از پیام بعدی ادامه دهید، نه از ابتدا.

این یعنی حفظ یک لاگ یا ژورنال مرتب از رویدادها در سمت سرور. یک آرایه در حافظه (in-memory) برای یک دمو مناسب است. در محیط عملیاتی (production) به چیزی پایدار نیاز دارید — مانند افزودن به لاگ پایگاه داده، یک Redis stream یا یک write-ahead journal — زیرا بازنشانی (restart) سرور نباید تاریخچه را پاک کند و همه کلاینت‌ها را مجبور کند از صفر شروع کنند.

رویدادهای خود را با یک عدد صحیح صعودی (monotonically increasing integer) یا یک ULID ایندکس کنید. وقتی درخواست اتصال مجدد می‌رسد، رویدادهایی را جستجو کنید که در آن‌ها id > lastEventId باشد و آن‌ها را به ترتیب بازپخش (replay) کنید. اگر صدها پیام انباشته شده دارید، یک تأخیر مصنوعی کوچک یا دسته‌بندی (batch) اعمال کنید، اما آن‌ها را از قدیمی‌ترین به جدیدترین بفرستید تا کلاینت بتواند وضعیت را به ترتیب زمانی بازسازی کند.

انتظار تکرار داشته باشید

شبکه‌ها قابل اعتماد نیستند. ممکن است سرور یک رویداد را ارسال کند، تاییدیه (acknowledgment) TCP را از دست بدهد و پس از اتمام زمان انتظار (timeout)، دوباره آن را ارسال کند. از همان ابتدا برای تحویل «حداقل یک‌بار» (at-least-once delivery) طراحی کنید.

در سمت کلاینت، حذف موارد تکراری (deduplication) هزینه‌ی کمی دارد. یک Map با کلیدِ ID رویداد نگه دارید. وقتی رویداد جدیدی رسید، مپ را بررسی کنید. اگر ID وجود داشت، تکراری را بی‌صدا حذف کنید. از آنجایی که سرور شما IDهای قطعی (deterministic) اختصاص می‌دهد، این کار تکرارها را بی‌خطر می‌کند. مپ نیازی ندارد که تا ابد رشد کند. وقتی تایید کردید که یک رویداد با موفقیت پردازش شده است، IDهای قدیمی‌تر را حذف کنید. یک پنجره لغزان (sliding window) شامل چند صد ورودی معمولاً برای کلاینت‌های مرورگر کافی است.

وقتی نشانگر منقضی می‌شود

در نهایت، یک کلاینت ممکن است پس از ساعت‌ها یا روزها دوباره متصل شود. اگر بافر تاریخچه شما فقط هزار رویداد آخر را پوشش دهد و کلاینت دو هزار پیام عقب مانده باشد، بازپخش شکاف‌ها غیرممکن است.

تاریخچه ناقص را استریم نکنید. این کار کلاینت را در یک وضعیت ناسازگار رها می‌کند. در عوض، انقضای نشانگر را تشخیص دهید و یک اسنپ‌شات (snapshot) کامل را به عنوان رویداد بعدی ارسال کنید. اسنپ‌شات باید نشانگر جدیدی را همراه داشته باشد که کلاینت را به وضعیت فعلی متصل کند. از آن پس، تغییرات لحظه‌ای (live deltas) به صورت عادی ادامه می‌یابند. این مرز را در پروتکل خود به وضوح مستند کنید تا کد کلاینت بداند چه زمانی باید مدل محلی خود را بازنشانی (reset) کند، نه اینکه فقط داده‌ها را اضافه (append) کند.

از جریان محافظت کنید

نقاط پایانی (endpoints) بازِ SSE اهداف جذابی هستند. هر کسی می‌تواند یک اتصال را باز نگه دارد و درخواست‌های بازپخش‌شده می‌توانند بار خواندن (read load) روی ذخیره‌ساز شما را تشدید کنند.

دسترسی به endpoint را با احراز هویت مناسب محدود کنید. از آنجایی که EventSource در مرورگر از هدرهای سفارشی پشتیبانی نمی‌کند، توکن را در رشته پرس‌وجو (query string) ارسال کنید یا از کوکی‌ها با سیاست‌های سخت‌گیرانه SameSite استفاده کنید. قبل از تخصیص منابع استریم، توکن را اعتبارسنجی کنید.

محدودیت‌های تاریخچه و سهمیه‌های هر کاربر را تعیین کنید. تعداد رویدادهای ذخیره‌شده در هر تسک و تعداد اتصالات همزمان برای هر کلاینت را محدود کنید. قطع اتصال‌ها و بازپخش‌ها (replays) را ثبت کنید تا بتوانید کلاینت مخربی را که به endpoint مربوط به cursor شما فشار می‌آورد، شناسایی کنید.

این الگو قابل انتقال است

این رویکرد محدود به HTTP نیست. همین قوانین زمانی که به سمت WebSocketها، صف‌های پیام (message queues) یا رابط‌های عامل-به-عامل (agent-to-agent) حرکت می‌کنید، نیز صدق می‌کنند. لایه انتقال تغییر می‌کند — ممکن است از فریم‌های باینری یا اشتراک در موضوعات (topic subscriptions) استفاده کنید — اما مشکل زیربنایی یکسان باقی می‌ماند. شما به یک cursor، یک لاگ بادوام (durable log)، معناشناسی حداقل یک‌بار ارسال (at-least-once semantics)، حذف داده‌های تکراری در سمت کلاینت (client deduplication) و یک مکانیزم جایگزین (fallback) به اسنپ‌شات‌های کامل در زمانی که cursor منقضی می‌شود، نیاز دارید. مسئله همگرایی وضعیت (state convergence) را یک‌بار حل کنید، آنگاه می‌توانید بدون بازطراحی منطق اصلی، آن را از طریق TCP، WebSocket یا یک کارگزار (broker) مانند RabbitMQ ارسال کنید.

ساده نگهش دارید

Server-Sent Events به این دلیل کار می‌کنند که بر پایه HTTP معمولی هستند. پروکسی‌ها آن‌ها را درک می‌کنند. لود بالانسرها می‌توانند سلامت آن‌ها را بررسی کنند. عیب‌یابی به سادگی curl است. اما اگر موارد خاص (edge cases) را نادیده بگیرید، این سادگی از بین می‌رود. cursor را بسازید. بازپخش‌ها را پیش‌بینی کنید. در سمت کلاینت داده‌های تکراری را حذف کنید. وقتی تاریخچه تمام شد، اسنپ‌شات بگیرید. با انجام این کار، تسک‌های طولانی‌مدت هوش مصنوعی شما، حتی در شرایط وای-فای ناپایدار، ری‌استارت شدن سرور و خواب رفتن گاه‌وبیگاه مرورگر در طول شب، پیشرفت خود را به درستی گزارش می‌دهند.

منبع: Build a Reconnecting SSE Task Stream with Node.js

در بحث‌ها شرکت کنید: GyaanSetu AI Community