11.4 ווב סוקטים וזמן אמת פתרון
פתרון - ווב-סוקטים וזמן אמת¶
פתרון תרגיל 1 - Hook בסיסי ל-WebSocket¶
// hooks/useWebSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
interface UseWebSocketReturn<T> {
send: (data: unknown) => void;
lastMessage: T | null;
readyState: number;
connectionStatus: ConnectionStatus;
}
function useWebSocket<T = unknown>(url: string): UseWebSocketReturn<T> {
const wsRef = useRef<WebSocket | null>(null);
const [lastMessage, setLastMessage] = useState<T | null>(null);
const [readyState, setReadyState] = useState<number>(WebSocket.CONNECTING);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting');
const retryCountRef = useRef(0);
const maxRetries = 5;
const connect = useCallback(() => {
try {
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
setReadyState(WebSocket.OPEN);
setConnectionStatus('connected');
retryCountRef.current = 0;
});
ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data) as T;
setLastMessage(data);
} catch {
setLastMessage(event.data as T);
}
});
ws.addEventListener('close', (event) => {
setReadyState(WebSocket.CLOSED);
if (event.code !== 1000 && retryCountRef.current < maxRetries) {
setConnectionStatus('reconnecting');
const delay = Math.min(
1000 * Math.pow(2, retryCountRef.current) + Math.random() * 500,
30000
);
retryCountRef.current += 1;
setTimeout(connect, delay);
} else {
setConnectionStatus('disconnected');
}
});
ws.addEventListener('error', () => {
setReadyState(WebSocket.CLOSED);
});
wsRef.current = ws;
} catch {
setConnectionStatus('disconnected');
}
}, [url]);
useEffect(() => {
connect();
return () => {
wsRef.current?.close(1000, 'Component unmounted');
};
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const message = typeof data === 'string' ? data : JSON.stringify(data);
wsRef.current.send(message);
}
}, []);
return { send, lastMessage, readyState, connectionStatus };
}
export default useWebSocket;
// דוגמת שימוש
function MessageDisplay() {
const { lastMessage, connectionStatus, send } = useWebSocket<{
text: string;
timestamp: string;
}>('wss://api.example.com/ws');
return (
<div>
<p>סטטוס: {connectionStatus}</p>
{lastMessage && (
<div>
<p>{lastMessage.text}</p>
<time>{lastMessage.timestamp}</time>
</div>
)}
<button onClick={() => send({ type: 'ping' })}>שלח Ping</button>
</div>
);
}
פתרון תרגיל 2 - אפליקציית צ'אט מלאה¶
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { io, type Socket } from 'socket.io-client';
interface ChatMessage {
id: string;
author: string;
text: string;
timestamp: string;
isOwn?: boolean;
}
interface ChatAppProps {
username: string;
serverUrl: string;
}
function ChatApp({ username, serverUrl }: ChatAppProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const socketRef = useRef<Socket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout>();
// חיבור Socket.io
useEffect(() => {
const socket = io(serverUrl, {
auth: { username },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socket.on('chat:message', (msg: ChatMessage) => {
setMessages((prev) => [
...prev,
{ ...msg, isOwn: msg.author === username },
]);
});
socket.on('chat:history', (history: ChatMessage[]) => {
setMessages(
history.map((m) => ({ ...m, isOwn: m.author === username }))
);
});
socket.on('chat:typing', ({ user, isTyping }: { user: string; isTyping: boolean }) => {
if (user === username) return;
setTypingUsers((prev) =>
isTyping
? [...new Set([...prev, user])]
: prev.filter((u) => u !== user)
);
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, [serverUrl, username]);
// גלילה אוטומטית
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// שליחת הודעה
const sendMessage = useCallback(() => {
if (!input.trim() || !socketRef.current) return;
socketRef.current.emit('chat:message', { text: input.trim() });
setInput('');
// ביטול אינדיקציית הקלדה
socketRef.current.emit('chat:typing', { isTyping: false });
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
}, [input]);
// אינדיקציית הקלדה
const handleTyping = useCallback(
(value: string) => {
setInput(value);
if (!socketRef.current) return;
socketRef.current.emit('chat:typing', { isTyping: true });
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
socketRef.current?.emit('chat:typing', { isTyping: false });
}, 2000);
},
[]
);
// עיצוב זמן
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('he-IL', {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', height: '80vh', display: 'flex', flexDirection: 'column' }}>
{/* סטטוס חיבור */}
<div
style={{
padding: '8px 16px',
backgroundColor: isConnected ? '#d4edda' : '#f8d7da',
textAlign: 'center',
borderRadius: '8px 8px 0 0',
}}
>
{isConnected ? 'מחובר' : 'מנותק - מנסה להתחבר מחדש...'}
</div>
{/* הודעות */}
<div
role="log"
aria-live="polite"
aria-label="הודעות צ'אט"
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
alignSelf: msg.isOwn ? 'flex-end' : 'flex-start',
backgroundColor: msg.isOwn ? '#0084ff' : '#e4e6eb',
color: msg.isOwn ? 'white' : 'black',
padding: '8px 12px',
borderRadius: '18px',
maxWidth: '70%',
}}
>
{!msg.isOwn && (
<strong style={{ fontSize: '0.8em', display: 'block' }}>
{msg.author}
</strong>
)}
<p style={{ margin: 0 }}>{msg.text}</p>
<time
style={{
fontSize: '0.7em',
opacity: 0.7,
display: 'block',
textAlign: 'end',
}}
>
{formatTime(msg.timestamp)}
</time>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* אינדיקציית הקלדה */}
{typingUsers.length > 0 && (
<p style={{ padding: '4px 16px', margin: 0, fontSize: '0.85em', color: '#666' }}>
{typingUsers.length === 1
? `${typingUsers[0]} מקליד/ה...`
: `${typingUsers.join(', ')} מקלידים...`}
</p>
)}
{/* שדה קלט */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
style={{ display: 'flex', gap: '8px', padding: '12px' }}
>
<input
type="text"
value={input}
onChange={(e) => handleTyping(e.target.value)}
placeholder="הקלד הודעה..."
disabled={!isConnected}
style={{ flex: 1, padding: '8px 12px', borderRadius: '20px', border: '1px solid #ccc' }}
aria-label="הודעת צ'אט"
/>
<button
type="submit"
disabled={!isConnected || !input.trim()}
style={{ padding: '8px 20px', borderRadius: '20px', border: 'none', backgroundColor: '#0084ff', color: 'white', cursor: 'pointer' }}
>
שלח
</button>
</form>
</div>
);
}
export default ChatApp;
פתרון תרגיל 3 - התראות בזמן אמת עם SSE¶
א. API Route:
// app/api/notifications/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// שליחת heartbeat כל 30 שניות
const heartbeatInterval = setInterval(() => {
controller.enqueue(
encoder.encode(`event: heartbeat\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`)
);
}, 30000);
// סימולציה של התראות
const notificationInterval = setInterval(() => {
const notification = {
id: crypto.randomUUID(),
title: 'התראה חדשה',
message: `עדכון בשעה ${new Date().toLocaleTimeString('he-IL')}`,
timestamp: new Date().toISOString(),
read: false,
};
controller.enqueue(
encoder.encode(`event: notification\ndata: ${JSON.stringify(notification)}\n\n`)
);
}, 10000);
// ניקוי אחרי 5 דקות
setTimeout(() => {
clearInterval(heartbeatInterval);
clearInterval(notificationInterval);
controller.close();
}, 300000);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
ב. Hook:
// hooks/useNotifications.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface Notification {
id: string;
title: string;
message: string;
timestamp: string;
read: boolean;
}
function useNotifications(url: string) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isConnected, setIsConnected] = useState(false);
const sourceRef = useRef<EventSource | null>(null);
useEffect(() => {
const source = new EventSource(url);
source.addEventListener('open', () => setIsConnected(true));
source.addEventListener('error', () => setIsConnected(false));
source.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data) as Notification;
setNotifications((prev) => [notification, ...prev]);
});
sourceRef.current = source;
return () => source.close();
}, [url]);
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, []);
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount, isConnected, markAsRead, markAllAsRead };
}
export default useNotifications;
ג. קומפוננטה:
'use client';
import { useState } from 'react';
import useNotifications from '@/hooks/useNotifications';
function NotificationBell() {
const [isOpen, setIsOpen] = useState(false);
const { notifications, unreadCount, markAsRead, markAllAsRead } =
useNotifications('/api/notifications/stream');
const formatRelativeTime = (timestamp: string) => {
const rtf = new Intl.RelativeTimeFormat('he', { numeric: 'auto' });
const diffMs = new Date(timestamp).getTime() - Date.now();
const diffMin = Math.round(diffMs / 60000);
const diffHour = Math.round(diffMin / 60);
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
if (Math.abs(diffHour) < 24) return rtf.format(diffHour, 'hour');
return rtf.format(Math.round(diffHour / 24), 'day');
};
return (
<div style={{ position: 'relative' }}>
<button
onClick={() => setIsOpen(!isOpen)}
aria-label={`התראות - ${unreadCount} לא נקראו`}
style={{ position: 'relative', padding: '8px', fontSize: '1.5em', background: 'none', border: 'none', cursor: 'pointer' }}
>
{'B'}
{unreadCount > 0 && (
<span
style={{
position: 'absolute',
top: 0,
insetInlineEnd: 0,
background: 'red',
color: 'white',
borderRadius: '50%',
width: '20px',
height: '20px',
fontSize: '0.5em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{unreadCount}
</span>
)}
</button>
{isOpen && (
<div
style={{
position: 'absolute',
insetInlineEnd: 0,
top: '100%',
width: '350px',
maxHeight: '400px',
overflowY: 'auto',
background: 'white',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px', borderBottom: '1px solid #eee' }}>
<strong>התראות</strong>
{unreadCount > 0 && (
<button onClick={markAllAsRead} style={{ border: 'none', background: 'none', color: '#0084ff', cursor: 'pointer' }}>
סמן הכל כנקרא
</button>
)}
</div>
{notifications.length === 0 ? (
<p style={{ padding: '24px', textAlign: 'center', color: '#999' }}>
אין התראות
</p>
) : (
notifications.map((notif) => (
<div
key={notif.id}
onClick={() => markAsRead(notif.id)}
style={{
padding: '12px',
borderBottom: '1px solid #f0f0f0',
backgroundColor: notif.read ? 'white' : '#f0f7ff',
cursor: 'pointer',
}}
>
<strong>{notif.title}</strong>
<p style={{ margin: '4px 0', fontSize: '0.9em' }}>{notif.message}</p>
<time style={{ fontSize: '0.8em', color: '#999' }}>
{formatRelativeTime(notif.timestamp)}
</time>
</div>
))
)}
</div>
)}
</div>
);
}
export default NotificationBell;
פתרון תרגיל 4 - חדר צ'אט עם נוכחות¶
'use client';
import { useState, useEffect, useCallback } from 'react';
import { io, type Socket } from 'socket.io-client';
interface RoomInfo {
name: string;
userCount: number;
}
interface UserPresence {
username: string;
joinedAt: string;
}
function ChatWithRooms({ username, serverUrl }: { username: string; serverUrl: string }) {
const [socket, setSocket] = useState<Socket | null>(null);
const [currentRoom, setCurrentRoom] = useState('general');
const [rooms] = useState<string[]>(['general', 'support', 'random']);
const [roomUsers, setRoomUsers] = useState<Record<string, UserPresence[]>>({});
const [roomCounts, setRoomCounts] = useState<Record<string, number>>({});
const [messages, setMessages] = useState<Record<string, ChatMessage[]>>({});
const [systemMessages, setSystemMessages] = useState<string[]>([]);
// חיבור
useEffect(() => {
const s = io(serverUrl, { auth: { username } });
s.on('connect', () => {
s.emit('room:join', { room: 'general' });
});
s.on('room:users', ({ room, users }: { room: string; users: UserPresence[] }) => {
setRoomUsers((prev) => ({ ...prev, [room]: users }));
});
s.on('room:counts', (counts: Record<string, number>) => {
setRoomCounts(counts);
});
s.on('room:user-joined', ({ room, user }: { room: string; user: string }) => {
setSystemMessages((prev) => [...prev, `${user} הצטרף/ה לחדר ${room}`]);
});
s.on('room:user-left', ({ room, user }: { room: string; user: string }) => {
setSystemMessages((prev) => [...prev, `${user} עזב/ה את חדר ${room}`]);
});
s.on('chat:message', (msg: ChatMessage) => {
setMessages((prev) => ({
...prev,
[msg.room || currentRoom]: [...(prev[msg.room || currentRoom] || []), msg],
}));
});
setSocket(s);
return () => { s.disconnect(); };
}, [serverUrl, username]);
const switchRoom = useCallback(
(newRoom: string) => {
if (!socket || newRoom === currentRoom) return;
socket.emit('room:leave', { room: currentRoom });
socket.emit('room:join', { room: newRoom });
setCurrentRoom(newRoom);
},
[socket, currentRoom]
);
const currentUsers = roomUsers[currentRoom] || [];
const currentMessages = messages[currentRoom] || [];
return (
<div style={{ display: 'flex', height: '80vh', gap: '16px' }}>
{/* רשימת חדרים */}
<div style={{ width: '200px', borderInlineEnd: '1px solid #eee', padding: '16px' }}>
<h3>חדרים</h3>
{rooms.map((room) => (
<button
key={room}
onClick={() => switchRoom(room)}
style={{
display: 'block',
width: '100%',
padding: '8px',
margin: '4px 0',
backgroundColor: room === currentRoom ? '#0084ff' : 'transparent',
color: room === currentRoom ? 'white' : 'inherit',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
textAlign: 'start',
}}
>
#{room} ({roomCounts[room] || 0})
</button>
))}
{/* משתמשים מחוברים */}
<h3 style={{ marginTop: '24px' }}>
מחוברים ({currentUsers.length})
</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{currentUsers.map((user) => (
<li key={user.username} style={{ padding: '4px 0', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#4caf50', display: 'inline-block' }} />
{user.username}
</li>
))}
</ul>
</div>
{/* אזור צ'אט */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<h2>#{currentRoom}</h2>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{currentMessages.map((msg) => (
<div key={msg.id} style={{ marginBottom: '8px' }}>
<strong>{msg.author}: </strong>
<span>{msg.text}</span>
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const input = form.querySelector('input') as HTMLInputElement;
if (input.value.trim() && socket) {
socket.emit('chat:message', { room: currentRoom, text: input.value.trim() });
input.value = '';
}
}}
style={{ display: 'flex', gap: '8px', padding: '12px' }}
>
<input
type="text"
placeholder={`הודעה ב-#${currentRoom}...`}
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid #ccc' }}
/>
<button type="submit" style={{ padding: '8px 16px' }}>שלח</button>
</form>
</div>
</div>
);
}
interface ChatMessage {
id: string;
author: string;
text: string;
timestamp: string;
room?: string;
}
export default ChatWithRooms;
פתרון תרגיל 5 - לוח שיתופי בזמן אמת¶
'use client';
import { useState, useEffect, useCallback } from 'react';
import { io, type Socket } from 'socket.io-client';
type Column = 'todo' | 'in-progress' | 'done';
interface Task {
id: string;
title: string;
column: Column;
lockedBy?: string;
}
const columnNames: Record<Column, string> = {
todo: 'לביצוע',
'in-progress': 'בתהליך',
done: 'הושלם',
};
function KanbanBoard({ username, serverUrl }: { username: string; serverUrl: string }) {
const [tasks, setTasks] = useState<Task[]>([]);
const [socket, setSocket] = useState<Socket | null>(null);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
useEffect(() => {
const s = io(serverUrl, { auth: { username } });
s.on('board:state', (state: Task[]) => {
setTasks(state);
});
s.on('board:task-moved', ({ taskId, toColumn, movedBy }: { taskId: string; toColumn: Column; movedBy: string }) => {
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, column: toColumn, lockedBy: undefined } : t))
);
});
s.on('board:task-locked', ({ taskId, lockedBy }: { taskId: string; lockedBy: string }) => {
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, lockedBy } : t))
);
});
s.on('board:task-unlocked', ({ taskId }: { taskId: string }) => {
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, lockedBy: undefined } : t))
);
});
setSocket(s);
return () => { s.disconnect(); };
}, [serverUrl, username]);
const handleDragStart = useCallback(
(taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (task?.lockedBy && task.lockedBy !== username) return;
setDraggedTask(taskId);
socket?.emit('board:lock-task', { taskId });
},
[socket, tasks, username]
);
const handleDrop = useCallback(
(column: Column) => {
if (!draggedTask || !socket) return;
socket.emit('board:move-task', { taskId: draggedTask, toColumn: column });
setDraggedTask(null);
},
[draggedTask, socket]
);
const handleDragEnd = useCallback(() => {
if (draggedTask) {
socket?.emit('board:unlock-task', { taskId: draggedTask });
setDraggedTask(null);
}
}, [draggedTask, socket]);
const columns: Column[] = ['todo', 'in-progress', 'done'];
return (
<div style={{ display: 'flex', gap: '16px', padding: '16px' }}>
{columns.map((column) => (
<div
key={column}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(column)}
style={{
flex: 1,
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
minHeight: '400px',
}}
>
<h3>{columnNames[column]} ({tasks.filter((t) => t.column === column).length})</h3>
{tasks
.filter((t) => t.column === column)
.map((task) => (
<div
key={task.id}
draggable={!task.lockedBy || task.lockedBy === username}
onDragStart={() => handleDragStart(task.id)}
onDragEnd={handleDragEnd}
style={{
padding: '12px',
marginBottom: '8px',
backgroundColor: 'white',
borderRadius: '6px',
border: task.lockedBy
? `2px solid ${task.lockedBy === username ? '#0084ff' : '#ff9800'}`
: '1px solid #ddd',
opacity: task.lockedBy && task.lockedBy !== username ? 0.7 : 1,
cursor: task.lockedBy && task.lockedBy !== username ? 'not-allowed' : 'grab',
}}
>
<p style={{ margin: 0 }}>{task.title}</p>
{task.lockedBy && (
<small style={{ color: '#999' }}>
{task.lockedBy === username ? 'את/ה מזיז/ה' : `${task.lockedBy} מזיז/ה`}
</small>
)}
</div>
))}
</div>
))}
</div>
);
}
export default KanbanBoard;
פתרון תרגיל 6 - סטטוס חיבור ו-Reconnection¶
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
interface QueuedMessage {
id: string;
data: unknown;
timestamp: number;
}
function useConnectionManager(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
const [retryCount, setRetryCount] = useState(0);
const [countdown, setCountdown] = useState(0);
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([]);
const maxRetries = 10;
const connect = useCallback(() => {
setStatus('reconnecting');
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
setStatus('connected');
setRetryCount(0);
setCountdown(0);
// שליחת הודעות מהתור
setMessageQueue((queue) => {
queue.forEach((msg) => {
ws.send(JSON.stringify(msg.data));
});
return [];
});
});
ws.addEventListener('close', (event) => {
if (event.code === 1000) {
setStatus('disconnected');
return;
}
setStatus('disconnected');
if (retryCount < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
setCountdown(Math.ceil(delay / 1000));
setRetryCount((prev) => prev + 1);
// ספירה לאחור
let remaining = Math.ceil(delay / 1000);
const countdownInterval = setInterval(() => {
remaining -= 1;
setCountdown(remaining);
if (remaining <= 0) clearInterval(countdownInterval);
}, 1000);
setTimeout(connect, delay);
}
});
wsRef.current = ws;
}, [url, retryCount]);
useEffect(() => {
connect();
return () => wsRef.current?.close(1000);
}, []);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
} else {
// הוספה לתור
setMessageQueue((prev) => [
...prev,
{ id: crypto.randomUUID(), data, timestamp: Date.now() },
]);
}
}, []);
const manualReconnect = useCallback(() => {
wsRef.current?.close();
setRetryCount(0);
connect();
}, [connect]);
return { status, retryCount, countdown, messageQueue, send, manualReconnect };
}
// קומפוננטת Banner
function ConnectionBanner() {
const { status, retryCount, countdown, messageQueue, manualReconnect } =
useConnectionManager('wss://api.example.com/ws');
if (status === 'connected') return null;
return (
<div
role="alert"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
padding: '12px 24px',
backgroundColor: status === 'reconnecting' ? '#fff3cd' : '#f8d7da',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 9999,
}}
>
<div>
<strong>
{status === 'reconnecting'
? `מנסה להתחבר מחדש... (ניסיון ${retryCount})`
: 'החיבור לשרת נותק'}
</strong>
{countdown > 0 && (
<span> - ניסיון הבא בעוד {countdown} שניות</span>
)}
{messageQueue.length > 0 && (
<span> | {messageQueue.length} הודעות בהמתנה</span>
)}
</div>
<button
onClick={manualReconnect}
style={{
padding: '6px 16px',
border: '1px solid #333',
borderRadius: '4px',
background: 'white',
cursor: 'pointer',
}}
>
נסה שוב
</button>
</div>
);
}
export { useConnectionManager, ConnectionBanner };
תשובות לשאלות¶
1. השוואת פרוטוקולים:
- WebSocket - חיבור דו-כיווני מתמשך. מתאים לצ'אט, משחקים, שיתוף פעולה בזמן אמת.
- SSE - חד-כיווני (שרת ללקוח), מעל HTTP רגיל. מתאים להתראות, עדכוני מחירים, פידים. פשוט יותר, יש reconnection אוטומטי.
- Long Polling - הלקוח שולח בקשת HTTP רגילה, השרת מחזיק אותה עד שיש מידע חדש. מתאים כשאין תמיכה ב-WebSocket. פחות יעיל.
2. Exponential Backoff:
Exponential Backoff הוא דפוס שבו הזמן בין ניסיונות חיבור מחדש הולך וגדל - 1 שנייה, 2 שניות, 4 שניות, 8 שניות וכו'. זה חשוב כי אם השרת נפל, אלפי לקוחות שמנסים להתחבר מחדש באותו זמן יעמיסו עליו. עם backoff, הניסיונות מתפזרים ומאפשרים לשרת להתאושש. הוספת jitter (מרכיב אקראי) מפזרת את הניסיונות עוד יותר.
3. יתרונות Socket.io:
Socket.io מוסיף שכבת אבסטרקציה מעל WebSocket: חיבור מחדש אוטומטי, fallback ל-HTTP long-polling אם WebSocket לא זמין, תמיכה בחדרים ו-namespaces, שליחת אירועים מותאמים (לא רק הודעות), acknowledgments לאישור קבלה, ותמיכה בבינארי. WebSocket רגיל דורש מימוש ידני של כל אלה.
4. הודעות בזמן ניתוק:
הדפוס הנפוץ הוא message queue - הודעות שנשלחות בזמן ניתוק נשמרות במערך מקומי (queue). כשהחיבור חוזר, כל ההודעות בתור נשלחות לשרת לפי הסדר. חשוב להגביל את גודל התור ולהוסיף timestamp כדי שהשרת יוכל לדעת מתי ההודעה נכתבה במקור.
5. emit מול broadcast:
emit שולח אירוע ללקוח ספציפי או לכל הלקוחות. broadcast שולח אירוע לכל הלקוחות חוץ מהשולח. למשל, כשמשתמש שולח הודעת צ'אט, השרת משתמש ב-socket.broadcast.emit כדי לשלוח את ההודעה לכל שאר המשתמשים בחדר, אבל לא בחזרה לשולח (כי הוא כבר רואה את ההודעה שלו).