تطبيق دردشة وإشعارات فورية
غرف، و«يكتب الآن…»، وإشعارات تصل فورًا ⏱ نحو 50 دقيقة.
- تصمم أحداث تطبيق فوري كما تصمم مسارات
API. - تبني دردشة بغرف وسجل رسائل ومؤشر كتابة.
- تتحقق من هوية الاتصال بوسيط
io.useوJWT. - ترسل إشعارات فورية لمستخدم بعينه من أي جزء في الخادم.
١ماذا يحتاج تطبيق الدردشة؟
نجمع في هذا الدرس ما تعلمناه في تطبيق دردشة حقيقي. قبل الكود، نصمم الأحداث: هي «الواجهة البرمجية» بين الطرفين، كما كانت المسارات في REST:
| الحدث | الاتجاه | البيانات | الرد |
|---|---|---|---|
join | عميل ← خادم | اسم الغرفة | ack: سجل الرسائل أو خطأ |
message | عميل ← خادم | نص الرسالة | ack: نجح أو خطأ |
message | خادم ← من في الغرفة | { from, text, at } | — |
system | خادم ← الغرفة عدا المرسِل | نص مثل «سارة دخلت» | — |
typing | في الاتجاهين | { name, on } | — |
٢الدردشة كاملة
نافذتان لمستخدمين. اختر الغرفة نفسها في النافذتين، ثم اكتب: تظهر الرسالة عند الطرفين، ويظهر «يكتب…» عند الآخر أثناء الكتابة، ورسالة نظام حين يدخل أحد الغرفة. وإن اختارا غرفتين مختلفتين فلن تصل الرسائل بينهما:
import { useState, useEffect, useRef } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) { const socket = useRef(null); const [room, setRoom] = useState(''); const [messages, setMessages] = useState([]); const [text, setText] = useState(''); const [typing, setTyping] = useState(''); useEffect(() => { const s = io('http://localhost:3000', { auth: { name: user } }); s.on('message', (m) => setMessages(prev => [...prev, m])); s.on('system', (t) => setMessages(prev => [...prev, { system: true, text: t }])); s.on('typing', ({ name, on }) => setTyping(on ? `${name} يكتب…` : '')); socket.current = s; return () => s.disconnect(); }, [user]); function join(r) { socket.current.emit('join', r, (res) => { if (res.error) return alert(res.error); setRoom(r); setMessages(res.history); }); } function send(e) { e.preventDefault(); socket.current.emit('message', text, (res) => { if (res.ok) setText(''); }); socket.current.emit('typing', false); } function onType(v) { setText(v); socket.current.emit('typing', v.length > 0); } if (!room) return ( <div> <p>مرحبًا {user}، اختر غرفة:</p> <button onClick={() => join('general')}># عامة</button> <button onClick={() => join('cs242')}># cs242</button> </div> ); return ( <div> <b># {room}</b> <ul className="msgs"> {messages.map((m, i) => m.system ? <li key={i} className="sys"><i>{m.text}</i></li> : <li key={i}><b>{m.from}</b>: {m.text} <small>{m.at}</small></li>)} </ul> <p className="typing">{typing}</p> <form onSubmit={send}> <input value={text} onChange={e => onType(e.target.value)} placeholder="اكتب رسالة" /> <button>إرسال</button> </form> </div> );}
const express = require('express');const http = require('http');const { Server } = require('socket.io'); const app = express();const server = http.createServer(app);const io = new Server(server, { cors: { origin: '*' } }); const history = { general: [], cs242: [] }; // آخر الرسائل لكل غرفة // وسيط الاتصال: يرفض من لا يرسل اسمًاio.use((socket, next) => { const name = socket.handshake.auth.name; if (!name) return next(new Error('الاسم مطلوب')); socket.data.name = name; next();}); io.on('connection', (socket) => { const name = socket.data.name; console.log('اتصل', name); socket.on('join', (room, ack) => { if (!history[room]) return ack({ error: 'غرفة غير موجودة' }); socket.rooms.forEach(r => r !== socket.id && socket.leave(r)); // غرفة واحدة في كل مرة socket.join(room); socket.to(room).emit('system', `${name} دخل الغرفة`); ack({ ok: true, history: history[room] }); }); socket.on('message', (text, ack) => { const room = [...socket.rooms].find(r => r !== socket.id); if (!room) return ack({ error: 'ادخل غرفة أولًا' }); if (!text || !text.trim()) return ack({ error: 'رسالة فارغة' }); const msg = { from: name, text: text.trim(), at: new Date().toISOString().slice(11, 16) }; history[room].push(msg); if (history[room].length > 50) history[room].shift(); io.to(room).emit('message', msg); ack({ ok: true }); }); socket.on('typing', (on) => { const room = [...socket.rooms].find(r => r !== socket.id); if (room) socket.to(room).emit('typing', { name, on }); }); socket.on('disconnect', () => console.log('خرج', name));}); server.listen(3000, () => console.log('خادم الدردشة يعمل'));
أهم ما في الخادم:
io.useوسيط يعمل مرة واحدة عند الاتصال (مثل وسيطExpress) ويرفض من لا اسم له؛ والعميل يتلقى عندها الحدثconnect_error.socket.dataمكان آمن لحفظ بيانات صاحب الاتصال (الاسم هنا، والمستخدم من الرمز لاحقًا).socket.roomsمجموعة الغرف التي فيها العميل، وأولها غرفته الخاصة باسم معرّفه.- السجل
historyيُرسل للقادم الجديد في الإقرار، ونحدّه بخمسين رسالة.
٣الهوية عند الاتصال بـ JWT
في التطبيق الحقيقي لا نصدّق اسمًا يرسله العميل؛ بل يرسل الرمز الذي أخذه عند تسجيل الدخول في auth، ويتحقق منه وسيط io.use:
// الواجهةconst socket = io(URL, { auth: { token } });socket.on('connect_error', (err) => setError(err.message)); // «رمز غير صالح» // الخادمio.use((socket, next) => { try { socket.data.user = jwt.verify(socket.handshake.auth.token, process.env.JWT_SECRET); next(); } catch { next(new Error('رمز غير صالح')); }});
جرّبه: الخادم هنا يقبل الرمز التجريبي good-token فقط. النافذة الثانية ترسل رمزًا خاطئًا:
import { useState, useEffect } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) { const [status, setStatus] = useState('…'); useEffect(() => { const token = user === 'سارة' ? 'good-token' : 'stolen-token'; const s = io('http://localhost:3000', { auth: { token } }); s.on('connect', () => setStatus('✓ متصل بهوية موثّقة')); s.on('connect_error', (err) => setStatus('✗ رُفض الاتصال: ' + err.message)); return () => s.disconnect(); }, [user]); return <p>{user}: {status}</p>;}
const http = require('http');const { Server } = require('socket.io');const server = http.createServer();const io = new Server(server, { cors: { origin: '*' } }); io.use((socket, next) => { // في المشروع: jwt.verify(socket.handshake.auth.token, SECRET) if (socket.handshake.auth.token !== 'good-token') return next(new Error('رمز غير صالح')); next();}); io.on('connection', () => console.log('اتصال موثّق'));server.listen(3000);
٤الإشعارات الفورية
الإشعار حدث يرسله الخادم لمستخدم بعينه مهما كانت الصفحة التي فتحها: «قُبل طلبك»، «رُصدت درجتك». الحيلة: عند الاتصال يدخل كل مستخدم غرفة باسمه user:رقمه، فيرسل الخادم إليها من أي مكان في الكود، حتى من مسار HTTP عادي:
import { useState, useEffect } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) { const [toasts, setToasts] = useState([]); useEffect(() => { const s = io('http://localhost:3000', { auth: { name: user } }); s.on('notify', (n) => setToasts(prev => [...prev, n])); return () => s.disconnect(); }, [user]); async function grade() { // المعلم يرصد درجة عبر REST، والخادم يُشعر الطالب فورًا await fetch('http://localhost:3000/api/grades', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student: 'ريم', grade: 95 }), }); } return ( <div> {user === 'المعلم' && <button onClick={grade}>ارصد درجة ريم</button>} {toasts.map((t, i) => <p key={i} className="toast">🔔 {t}</p>)} {user !== 'المعلم' && !toasts.length && <p>لا إشعارات بعد…</p>} </div> );}
const express = require('express');const http = require('http');const { Server } = require('socket.io');const app = express();app.use(express.json());const server = http.createServer(app);const io = new Server(server, { cors: { origin: '*' } }); io.on('connection', (socket) => { socket.join('user:' + socket.handshake.auth.name); // غرفة خاصة لكل مستخدم}); app.post('/api/grades', (req, res) => { const { student, grade } = req.body; // ... الحفظ في قاعدة البيانات ... io.to('user:' + student).emit('notify', `رُصدت درجتك: ${grade}`); res.status(201).json({ ok: true });}); server.listen(3000);
توقّع وتحقّق
أين يُتحقق من هوية مستخدم Socket.io قبل قبول اتصاله؟
Express.كيف يرسل الخادم إشعارًا لمستخدم واحد بعينه؟
ماذا يستقبل العميل حين يرفض io.use اتصاله؟
next(new Error).لماذا يضيف الخادم اسم المرسِل والوقت بنفسه؟
الخلاصة
- صمّم الأحداث أولًا: الاسم، والاتجاه، والبيانات، والرد.
- الغرف:
joinوto(room)، وsocket.roomsللغرفة الحالية. io.use + handshake.auth.tokenللتحقق، وsocket.dataلحفظ الهوية.- غرفة خاصة لكل مستخدم = إشعارات موجّهة، حتى من مسارات
REST. - الخادم يضيف المرسِل والوقت ويتحقق من كل حدث.
تحدٍّ برمجي: غرفة الدعم الفني
متقدمالواجهة جاهزة في App.jsx. أكمل الخادم في server/server.js.
- الحدث
join(الغرفة، ack): أدخل العميل الغرفة وردack({ ok: true }). - الحدث
say(النص، ack): إن لم يكن العميل في غرفة غير غرفته الخاصة ردack({ error: 'ادخل غرفة أولًا' }). - وإن كان النص فارغًا رد
ack({ error: 'رسالة فارغة' }). - وإلا أرسل للغرفة كلها الحدث
saidبالكائن{ from: اسم المتصل من auth، text }وردack({ ok: true }).
👀 اعرض الناتج المطلوب (وجرّبه)
- الكود يعمل بلا أخطاء في Console
- الإرسال قبل الدخول يعطي «ادخل غرفة أولًا»
- الدخول يرد بالنجاح
- الرسالة تصل للغرفة باسم المرسِل
- الرسالة الفارغة تُرفض ولا تُبث
- يبث بـ
io.to(room).emit
💡 تلميح 1
[...socket.rooms].find(r => r !== socket.id).💡 تلميح 2
socket.handshake.auth.name.حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
import { useState, useEffect, useRef } from 'react';import { io } from 'socket.io-client'; // الواجهة جاهزة: تدخل الغرفة support وترسل رسالة، وتعرض ما يصل بالحدث saidexport default function App({ user = 'ضيف' }) { const s = useRef(null); const [log, setLog] = useState([]); const [err, setErr] = useState(''); useEffect(() => { s.current = io('http://localhost:3000', { auth: { name: user } }); s.current.on('said', (m) => setLog(prev => [...prev, m.from + ': ' + m.text])); return () => s.current.disconnect(); }, [user]); const join = () => s.current.emit('join', 'support', (r) => setErr(r.error || 'دخلت support')); const say = (t) => s.current.emit('say', t, (r) => setErr(r.error || '')); return ( <div> <button onClick={join}>ادخل support</button> <button onClick={() => say('أحتاج مساعدة')}>أرسل</button> <button onClick={() => say('')}>أرسل فارغة</button> <p className="st">{err}</p> <ul className="log">{log.map((l, i) => <li key={i}>{l}</li>)}</ul> </div> );}
const http = require('http');const { Server } = require('socket.io');const server = http.createServer();const io = new Server(server, { cors: { origin: '*' } }); io.on('connection', (socket) => { const name = socket.handshake.auth.name; socket.on('join', (room, ack) => { socket.join(room); ack({ ok: true }); }); socket.on('say', (text, ack) => { const room = [...socket.rooms].find(r => r !== socket.id); if (!room) return ack({ error: 'ادخل غرفة أولًا' }); if (!text || !text.trim()) return ack({ error: 'رسالة فارغة' }); io.to(room).emit('said', { from: name, text }); ack({ ok: true }); });}); server.listen(3000);