לדלג לתוכן

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;
// Usage example
function MessageDisplay() {
  const { lastMessage, connectionStatus, send } = useWebSocket<{
    text: string;
    timestamp: string;
  }>('wss://api.example.com/ws');

  return (
    <div>
      <p>Status: {connectionStatus}</p>
      {lastMessage && (
        <div>
          <p>{lastMessage.text}</p>
          <time>{lastMessage.timestamp}</time>
        </div>
      )}
      <button onClick={() => send({ type: 'ping' })}>Send 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 connection
  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]);

  // Automatic scrolling
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // Send message
  const sendMessage = useCallback(() => {
    if (!input.trim() || !socketRef.current) return;

    socketRef.current.emit('chat:message', { text: input.trim() });
    setInput('');

    // Cancel typing indicator
    socketRef.current.emit('chat:typing', { isTyping: false });
    if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
  }, [input]);

  // Typing indicator
  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);
    },
    []
  );

  // Time formatting
  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' }}>
      {/* Connection status */}
      <div
        style={{
          padding: '8px 16px',
          backgroundColor: isConnected ? '#d4edda' : '#f8d7da',
          textAlign: 'center',
          borderRadius: '8px 8px 0 0',
        }}
      >
        {isConnected ? 'Connected' : 'Disconnected - trying to reconnect...'}
      </div>

      {/* Messages */}
      <div
        role="log"
        aria-live="polite"
        aria-label="Chat messages"
        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>

      {/* Typing indicator */}
      {typingUsers.length > 0 && (
        <p style={{ padding: '4px 16px', margin: 0, fontSize: '0.85em', color: '#666' }}>
          {typingUsers.length === 1
            ? `${typingUsers[0]} is typing...`
            : `${typingUsers.join(', ')} are typing...`}
        </p>
      )}

      {/* Input field */}
      <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="Type a message..."
          disabled={!isConnected}
          style={{ flex: 1, padding: '8px 12px', borderRadius: '20px', border: '1px solid #ccc' }}
          aria-label="Chat message"
        />
        <button
          type="submit"
          disabled={!isConnected || !input.trim()}
          style={{ padding: '8px 20px', borderRadius: '20px', border: 'none', backgroundColor: '#0084ff', color: 'white', cursor: 'pointer' }}
        >
          Send
        </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) {
      // Send a heartbeat every 30 seconds
      const heartbeatInterval = setInterval(() => {
        controller.enqueue(
          encoder.encode(`event: heartbeat\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`)
        );
      }, 30000);

      // Simulate notifications
      const notificationInterval = setInterval(() => {
        const notification = {
          id: crypto.randomUUID(),
          title: 'New notification',
          message: `Update at ${new Date().toLocaleTimeString('he-IL')}`,
          timestamp: new Date().toISOString(),
          read: false,
        };

        controller.enqueue(
          encoder.encode(`event: notification\ndata: ${JSON.stringify(notification)}\n\n`)
        );
      }, 10000);

      // Cleanup after 5 minutes
      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={`Notifications - ${unreadCount} unread`}
        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>Notifications</strong>
            {unreadCount > 0 && (
              <button onClick={markAllAsRead} style={{ border: 'none', background: 'none', color: '#0084ff', cursor: 'pointer' }}>
                Mark all as read
              </button>
            )}
          </div>

          {notifications.length === 0 ? (
            <p style={{ padding: '24px', textAlign: 'center', color: '#999' }}>
              No notifications
            </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[]>([]);

  // Connection
  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} joined room ${room}`]);
    });

    s.on('room:user-left', ({ room, user }: { room: string; user: string }) => {
      setSystemMessages((prev) => [...prev, `${user} left room ${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' }}>
      {/* Room list */}
      <div style={{ width: '200px', borderInlineEnd: '1px solid #eee', padding: '16px' }}>
        <h3>Rooms</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>
        ))}

        {/* Connected users */}
        <h3 style={{ marginTop: '24px' }}>
          Connected ({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>

      {/* Chat area */}
      <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={`Message in #${currentRoom}...`}
            style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid #ccc' }}
          />
          <button type="submit" style={{ padding: '8px 16px' }}>Send</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: 'To Do',
  'in-progress': 'In Progress',
  done: '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 ? 'You are moving this' : `${task.lockedBy} is moving this`}
                  </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);

      // Send messages from the queue
      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);

        // Countdown
        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 {
      // Add to the queue
      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 component
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'
            ? `Reconnecting... (attempt ${retryCount})`
            : 'Connection to the server was lost'}
        </strong>
        {countdown > 0 && (
          <span> - next attempt in {countdown} seconds</span>
        )}
        {messageQueue.length > 0 && (
          <span> | {messageQueue.length} messages pending</span>
        )}
      </div>

      <button
        onClick={manualReconnect}
        style={{
          padding: '6px 16px',
          border: '1px solid #333',
          borderRadius: '4px',
          background: 'white',
          cursor: 'pointer',
        }}
      >
        Try again
      </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, שליחת events מותאמים (לא רק הודעות), acknowledgments לאישור קבלה, ותמיכה בבינארי. WebSocket רגיל דורש מימוש ידני של כל אלה.

4. הודעות בזמן ניתוק:

הדפוס הנפוץ הוא message queue - הודעות שנשלחות בזמן ניתוק נשמרות במערך מקומי (queue). כשהחיבור חוזר, כל ההודעות בqueue נשלחות לשרת לפי הסדר. חשוב להגביל את גודל הqueue ולהוסיף timestamp כדי שהשרת יוכל לדעת מתי ההודעה נכתבה במקור.

5. emit מול broadcast:

emit שולח event ללקוח ספציפי או לכל הלקוחות. broadcast שולח event לכל הלקוחות חוץ מהשולח. למשל, כשמשתמש שולח הודעת צ'אט, השרת משתמש ב-socket.broadcast.emit כדי לשלוח את ההודעה לכל שאר המשתמשים בחדר, אבל לא בחזרה לשולח (כי הוא כבר רואה את ההודעה שלו).