تعد الأحداث المرسلة من الخادم هي أبسط طريقة لدفع البيانات من الخادم إلى المتصفح. وهي تعمل عبر بروتوكول HTTP العادي، وتتم إعادة الاتصال تلقائيًا، ولا تحتاج إلى بروتوكول إضافي. بالنسبة للوحات المعلومات والإشعارات وتحديثات التقدم واستجابات الذكاء الاصطناعي المتدفقة – أي شيء تتدفق فيه البيانات في اتجاه واحد – عادةً ما يكون SSE هو الاختيار الصحيح عبر WebSockets.
📋 Table of Contents
SSE أو WebSockets؟
| سس | ويب سوكيتس | |
|---|---|---|
| الاتجاه | خادم للعميل فقط | ثنائي الاتجاه |
| البروتوكول | HTTP عادي | الترقية إلى ws:// |
| إعادة الاتصال التلقائي | بنيت في | أنت تنفذه |
| تنسيق البيانات | نص UTF-8 | نص أو ثنائي |
| سهولة الوكيل | عادة بخير | يحتاج إلى دعم الترقية |
| التعقيد | منخفض | العالي |
استخدمسس للوحات المعلومات المباشرة، وموجزات الإشعارات، والتقدم في المهمة، وتتبع السجل، ومخرجات الذكاء الاصطناعي لكل رمز مميز. استخدمويب سوكيتس عندما يرسل العميل رسائل متكررة أيضًا – الدردشة، والتحرير التعاوني، والألعاب متعددة اللاعبين.
مزيج شائع ومعقول: SSE لتحديثات الخادم، وطلبات HTTP POST العادية لإجراءات العميل. يغطي هذا معظم التطبيقات بدون بروتوكول ثانٍ.
تنسيق السلك
SSE عبارة عن دفق نص عادي بتنسيق صغير وصارم.
data: hello world
event: userUpdate
data: {"id":1,"name":"Ada"}
id: 42
retry: 5000
data: message with an id and a retry hint
قاعدتان تسببان كل خطأ تقريبًا. تنتهي كل رسالة بـاثنان خطوط جديدة. والبيانات متعددة الأسطر تحتاج إلىdata: بادئة في كل سطر، ولهذا السبب يجب ألا تحتوي حمولات JSON على أسطر جديدة أولية.
الخادم: Node.js مع Express
import express from 'express';
const app = express();
// Track connected clients so we can broadcast.
const clients = new Set();
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// Tell nginx not to buffer this response.
'X-Accel-Buffering': 'no',
});
// Flush headers immediately so the client's connection opens.
res.flushHeaders();
const client = { id: Date.now(), res };
clients.add(client);
send(res, { type: 'connected', at: new Date().toISOString() });
// A comment line every 25s keeps proxies from closing an idle connection.
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(client);
});
});
function send(res, data, event) {
if (event) res.write(`event: ${event}\n`);
// JSON.stringify never emits a raw newline, which keeps the frame valid.
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function broadcast(data, event) {
for (const client of clients) {
send(client.res, data, event);
}
}
app.listen(3000);
ثلاثة تفاصيل مهمة هنا. flushHeaders() يفتح الدفق على الفور بدلاً من انتظار الكتابة الأولى. يمنع تعليق نبضات القلب الوسطاء من إسقاط اتصال خامل. وX-Accel-Buffering: no يمنع nginx من تخزين الاستجابة مؤقتًا، وهو السبب الأكثر شيوعًا وراء عمل SSE محليًا وفشله في الإنتاج.
العميل: رد
import { useEffect, useRef, useState } from 'react';
export function useEventStream(url) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('connecting');
const sourceRef = useRef(null);
useEffect(() => {
const source = new EventSource(url, { withCredentials: true });
sourceRef.current = source;
source.onopen = () => setStatus('open');
source.onmessage = (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, data]);
};
// Named events need their own listener.
source.addEventListener('userUpdate', (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, { ...data, kind: 'userUpdate' }]);
});
source.onerror = () => {
// EventSource reconnects on its own unless the state is CLOSED.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting');
};
return () => source.close();
}, [url]);
return { messages, status };
}
export function LiveFeed() {
const { messages, status } = useEventStream('/api/events');
return (
<div>
<p>Status: {status}</p>
<ul>
{messages.map((m, i) => <li key={i}>{JSON.stringify(m)}</li>)}
</ul>
</div>
);
}
العودةsource.close() من التأثير ليس اختياريا. بدونه، يترك التثبيت المزدوج في React Strict Mode اتصالاً معزولًا، ويؤدي التنقل حول التطبيق إلى تراكم التدفقات المفتوحة حتى يتم الوصول إلى حد الاتصال لكل مجال في المتصفح ويتوقف كل شيء.
الاستئناف بعد قطع الاتصال
عندما ترسلid: يقوم المتصفح بتخزينه وإرساله مرة أخرى كـLast-Event-ID على إعادة الاتصال. يتيح لك ذلك إعادة تشغيل ما فاتك فقط.
app.get('/api/events', (req, res) => {
// ... headers as above ...
const lastId = req.headers['last-event-id'];
if (lastId) {
for (const event of getEventsSince(Number(lastId))) {
res.write(`id: ${event.id}\n`);
res.write(`data: ${JSON.stringify(event.payload)}\n\n`);
}
}
});
يؤدي هذا إلى تحويل SSE من أفضل جهد إلى شيء أقرب إلى التسليم الموثوق، وهو أمر مهم لموجزات الإشعارات حيث تكون الرسالة المسقطة مرئية للمستخدم.
المصادقة
الأصليEventSource لا يمكن لواجهة برمجة التطبيقات (API) تعيين رؤوس مخصصة، وهو ما يفاجئ الأشخاص الذين يقومون بإنشاء واجهات برمجة تطبيقات (APIs) مصادق عليها بالرمز المميز. ثلاث طرق عملية:
ملفات تعريف الارتباط — أبسط. تمريرwithCredentials: true والسماح لملف تعريف ارتباط الجلسة بالمصادقة على الطلب كما يفعل مع أي ملف تعريف ارتباط آخر.
رمز مميز قصير العمر في سلسلة الاستعلام — مقبول فقط إذا كان الرمز مميزًا للاستخدام مرة واحدة وتنتهي صلاحيته خلال دقائق، لأن عناوين URL ينتهي بها الأمر في سجلات الخادم.
جلب مع القارئ المتدفق — التحكم الكامل في الرأس، على حساب تنفيذ إعادة الاتصال بنفسك.
async function streamWithAuth(url, token, onMessage) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? ''; // keep the incomplete frame
for (const frame of frames) {
const line = frame.split('\n').find(l => l.startsWith('data:'));
if (line) onMessage(JSON.parse(line.slice(5).trim()));
}
}
}
لاحظ التخزين المؤقت: يمكن لمجموعة الشبكة تقسيم الإطار إلى نصفين، لذا يجب عليك الاحتفاظ بالباقي وتحليل الإطارات الكاملة فقط. يؤدي تحليل كل قطعة بشكل مستقل إلى ظهور أخطاء JSON متقطعة يصعب تشخيصها.
بث استجابات الذكاء الاصطناعي
النمط الكامن وراء إخراج الرمز المميز في واجهات الدردشة.
app.post('/api/chat', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
try {
for await (const chunk of generateResponse(req.body.prompt)) {
res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);
}
res.write('data: [DONE]\n\n');
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
} finally {
res.end();
}
});
تكوين الوكيل والنشر
معظم حالات فشل SSE للإنتاج هي تخزين مؤقت للوكيل. الرأس وحده لا يكفي دائمًا.
# nginx
location /api/events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
chunked_transfer_encoding off;
}
انتبه أيضًا إلى أن الأنظمة الأساسية التي لا تحتوي على خادم غالبًا ما تحدد مدة الاستجابة، مما يجعل اتصالات SSE طويلة الأمد غير مناسبة. تحقق من حدود النظام الأساسي الخاص بك قبل التصميم حوله. ومع HTTP/1.1، تسمح المتصفحات بستة اتصالات فقط لكل نطاق – يزيل HTTP/2 هذا القيد، لذا يمكنك تقديم SSE عبر HTTP/2 حيثما أمكنك ذلك.
أخطاء شائعة
نسيان السطر الجديد الثاني. يتم إرسال الرسالة أبداً ويظهر العميل معطلاً.
عدم إغلاق مصدر الحدث عند إلغاء التحميل. تتراكم الاتصالات حتى يتم الوصول إلى حد المتصفح.
خطوط جديدة خام داخل البيانات. دائماJSON.stringify الحمولة.
لا نبضات القلب. يتم إغلاق الاتصالات الخاملة بواسطة الوسطاء بعد دقيقة أو دقيقتين.
تم ترك التخزين المؤقت للوكيل قيد التشغيل. كل شيء يعمل محليًا، ولا يصل أي شيء إلى الإنتاج حتى تنتهي الاستجابة.
استخدام SSE لحركة المرور ثنائية الاتجاه. إذا كان العميل يرسل رسائل بشكل متكرر، فاستخدم WebSockets.
الخلاصة
يمنحك SSE البث من خادم إلى عميل عبر HTTP العادي مع إعادة الاتصال التلقائي والقليل جدًا من التعليمات البرمجية. احصل على خمسة أشياء صحيحة:قم بإنهاء كل رسالة بسطرين جديدين، وأرسل تعليقًا دوريًا على شكل نبضات، وقم بتعطيل التخزين المؤقت للوكيل باستخدام كل من الرأس وتكوين nginx، وأغلق EventSource في تنظيف التأثير، واستخدمid: معLast-Event-ID عندما سيتم ملاحظة الرسائل الفائتة. يمكنك الوصول إلى WebSockets فقط عندما يحتاج العميل حقًا إلى إرسال رسائل متكررة مرة أخرى.
🔗 Share this article
✍️ Leave a Comment