לדלג לתוכן

11.4 ווב סוקטים וזמן אמת הרצאה

ווב-סוקטים וזמן אמת - WebSockets and Real-time

תקשורת בזמן אמת היא חלק חיוני מאפליקציות מודרניות - צ'אט, התראות חיות, עדכוני מחירים, שיתוף פעולה בזמן אמת ועוד. בשיעור זה נלמד על הפרוטוקולים והכלים שמאפשרים תקשורת דו-כיוונית בין הדפדפן לשרת.


HTTP מול WebSocket

המודל הרגיל - HTTP Request/Response

לקוח                          שרת
  |  --- GET /messages ------>  |
  |  <--- 200 OK + data -----  |
  |                             |
  |  (5 שניות מאוחר יותר)       |
  |  --- GET /messages ------>  |
  |  <--- 200 OK + data -----  |
  • בקשה ותגובה - הלקוח שואל, השרת עונה
  • כל בקשה פותחת חיבור חדש
  • אם רוצים עדכונים - צריך לשאול שוב ושוב (polling)
  • לא יעיל לתקשורת בזמן אמת

המודל של WebSocket

לקוח                          שרת
  |  --- HTTP Upgrade ------> |
  |  <--- 101 Switching ---   |
  |                            |
  |  <=== חיבור פתוח ====>    |
  |  --- הודעה 1 -----------> |
  |  <--- הודעה 2 ----------  |
  |  <--- הודעה 3 ----------  |
  |  --- הודעה 4 -----------> |
  |  <=== חיבור פתוח ====>    |
  • חיבור אחד שנשאר פתוח
  • תקשורת דו-כיוונית - שני הצדדים יכולים לשלוח הודעות
  • יעיל - אין overhead של פתיחת חיבורים חדשים
  • מעודכן מיד - השרת יכול לשלוח הודעות ללא שהלקוח מבקש

WebSocket API בדפדפן

שימוש בסיסי

// יצירת חיבור WebSocket
const ws = new WebSocket('wss://api.example.com/ws');

// אירוע פתיחת חיבור
ws.addEventListener('open', () => {
  console.log('חיבור נפתח');
  ws.send('שלום שרת!');
});

// אירוע קבלת הודעה
ws.addEventListener('message', (event) => {
  console.log('התקבלה הודעה:', event.data);

  // אם ההודעה היא JSON
  const data = JSON.parse(event.data);
  console.log(data);
});

// אירוע סגירת חיבור
ws.addEventListener('close', (event) => {
  console.log('החיבור נסגר:', event.code, event.reason);
});

// אירוע שגיאה
ws.addEventListener('error', (event) => {
  console.error('שגיאת WebSocket:', event);
});

// שליחת הודעה
ws.send(JSON.stringify({ type: 'chat', message: 'שלום!' }));

// סגירת החיבור
ws.close();

Hook לריאקט - useWebSocket

import { useEffect, useRef, useState, useCallback } from 'react';

interface UseWebSocketOptions {
  url: string;
  onMessage?: (data: unknown) => void;
  onOpen?: () => void;
  onClose?: () => void;
  onError?: (error: Event) => void;
  reconnect?: boolean;
  reconnectInterval?: number;
  maxRetries?: number;
}

type ReadyState = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED';

function useWebSocket({
  url,
  onMessage,
  onOpen,
  onClose,
  onError,
  reconnect = true,
  reconnectInterval = 3000,
  maxRetries = 5,
}: UseWebSocketOptions) {
  const wsRef = useRef<WebSocket | null>(null);
  const retriesRef = useRef(0);
  const [readyState, setReadyState] = useState<ReadyState>('CONNECTING');

  const connect = useCallback(() => {
    const ws = new WebSocket(url);

    ws.addEventListener('open', () => {
      setReadyState('OPEN');
      retriesRef.current = 0;
      onOpen?.();
    });

    ws.addEventListener('message', (event) => {
      try {
        const data = JSON.parse(event.data);
        onMessage?.(data);
      } catch {
        onMessage?.(event.data);
      }
    });

    ws.addEventListener('close', () => {
      setReadyState('CLOSED');
      onClose?.();

      // ניסיון התחברות מחדש
      if (reconnect && retriesRef.current < maxRetries) {
        retriesRef.current += 1;
        setTimeout(connect, reconnectInterval);
      }
    });

    ws.addEventListener('error', (event) => {
      onError?.(event);
    });

    wsRef.current = ws;
  }, [url, onMessage, onOpen, onClose, onError, reconnect, reconnectInterval, maxRetries]);

  useEffect(() => {
    connect();

    return () => {
      wsRef.current?.close();
    };
  }, [connect]);

  const send = useCallback((data: unknown) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(
        typeof data === 'string' ? data : JSON.stringify(data)
      );
    }
  }, []);

  return { send, readyState };
}

export default useWebSocket;

Socket.io - לקוח

Socket.io היא ספרייה שמפשטת עבודה עם WebSockets ומוסיפה יכולות כמו חדרים, reconnection אוטומטי, ו-fallback ל-polling.

npm install socket.io-client

שימוש בסיסי

import { io, type Socket } from 'socket.io-client';

// יצירת חיבור
const socket: Socket = io('https://api.example.com', {
  autoConnect: false,
  auth: {
    token: 'user-auth-token',
  },
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: 1000,
});

// חיבור ידני
socket.connect();

// שליחת אירוע
socket.emit('chat:message', { text: 'שלום!', room: 'general' });

// האזנה לאירוע
socket.on('chat:message', (data) => {
  console.log('הודעה חדשה:', data);
});

// שליחה עם acknowledgment
socket.emit('chat:message', { text: 'שלום!' }, (response: { ok: boolean }) => {
  console.log('השרת אישר:', response);
});

// ניתוק
socket.disconnect();

Hook לריאקט עם Socket.io

// hooks/useSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, type Socket } from 'socket.io-client';

interface UseSocketOptions {
  url: string;
  auth?: Record<string, string>;
}

function useSocket({ url, auth }: UseSocketOptions) {
  const socketRef = useRef<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const socket = io(url, {
      auth,
      autoConnect: true,
      reconnection: true,
      reconnectionAttempts: 5,
    });

    socket.on('connect', () => setIsConnected(true));
    socket.on('disconnect', () => setIsConnected(false));

    socketRef.current = socket;

    return () => {
      socket.disconnect();
    };
  }, [url, auth]);

  const emit = useCallback((event: string, data?: unknown) => {
    socketRef.current?.emit(event, data);
  }, []);

  const on = useCallback((event: string, handler: (...args: unknown[]) => void) => {
    socketRef.current?.on(event, handler);
    return () => {
      socketRef.current?.off(event, handler);
    };
  }, []);

  return { emit, on, isConnected, socket: socketRef.current };
}

export default useSocket;

בניית קומפוננטת צ'אט בזמן אמת

'use client';

import { useState, useEffect, useRef, useCallback } from 'react';
import useSocket from '@/hooks/useSocket';

interface ChatMessage {
  id: string;
  author: string;
  text: string;
  timestamp: Date;
}

interface ChatProps {
  room: string;
  username: string;
}

function Chat({ room, username }: ChatProps) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const typingTimeoutRef = useRef<NodeJS.Timeout>();

  const { emit, on, isConnected } = useSocket({
    url: process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001',
    auth: { username },
  });

  // הצטרפות לחדר
  useEffect(() => {
    emit('room:join', { room });

    return () => {
      emit('room:leave', { room });
    };
  }, [room, emit]);

  // האזנה להודעות
  useEffect(() => {
    const unsubMessage = on('chat:message', (data: unknown) => {
      const message = data as ChatMessage;
      setMessages((prev) => [...prev, { ...message, timestamp: new Date(message.timestamp) }]);
    });

    const unsubHistory = on('chat:history', (data: unknown) => {
      const history = data as ChatMessage[];
      setMessages(history.map((m) => ({ ...m, timestamp: new Date(m.timestamp) })));
    });

    const unsubTyping = on('chat:typing', (data: unknown) => {
      const { user, isTyping } = data as { user: string; isTyping: boolean };
      setTypingUsers((prev) =>
        isTyping
          ? [...prev.filter((u) => u !== user), user]
          : prev.filter((u) => u !== user)
      );
    });

    return () => {
      unsubMessage();
      unsubHistory();
      unsubTyping();
    };
  }, [on]);

  // גלילה אוטומטית למטה
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // שליחת הודעה
  const sendMessage = useCallback(() => {
    if (!inputValue.trim()) return;

    emit('chat:message', {
      room,
      text: inputValue.trim(),
    });

    setInputValue('');
  }, [inputValue, room, emit]);

  // אינדיקציית הקלדה
  const handleInputChange = useCallback(
    (value: string) => {
      setInputValue(value);

      emit('chat:typing', { room, isTyping: true });

      // ביטול timeout קודם
      if (typingTimeoutRef.current) {
        clearTimeout(typingTimeoutRef.current);
      }

      // הפסקת אינדיקציה אחרי 2 שניות ללא הקלדה
      typingTimeoutRef.current = setTimeout(() => {
        emit('chat:typing', { room, isTyping: false });
      }, 2000);
    },
    [room, emit]
  );

  return (
    <div className="chat-container">
      {/* סטטוס חיבור */}
      <div className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
        {isConnected ? 'מחובר' : 'מנותק...'}
      </div>

      {/* הודעות */}
      <div className="messages" role="log" aria-live="polite">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`message ${msg.author === username ? 'own' : 'other'}`}
          >
            <strong>{msg.author}</strong>
            <p>{msg.text}</p>
            <time>{new Date(msg.timestamp).toLocaleTimeString()}</time>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      {/* אינדיקציית הקלדה */}
      {typingUsers.length > 0 && (
        <p className="typing-indicator">
          {typingUsers.join(', ')} מקלידים...
        </p>
      )}

      {/* שדה קלט */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage();
        }}
        className="input-area"
      >
        <input
          type="text"
          value={inputValue}
          onChange={(e) => handleInputChange(e.target.value)}
          placeholder="הקלד הודעה..."
          disabled={!isConnected}
        />
        <button type="submit" disabled={!isConnected || !inputValue.trim()}>
          שלח
        </button>
      </form>
    </div>
  );
}

export default Chat;

אירועים שנשלחים מהשרת - Server-Sent Events (SSE)

SSE הוא פרוטוקול חד-כיווני - השרת שולח אירועים ללקוח. פשוט יותר מ-WebSocket אך מוגבל לכיוון אחד.

מתי להשתמש ב-SSE במקום WebSocket?

  • עדכוני חדשות או מחירים (הלקוח רק מקבל)
  • התראות בזמן אמת
  • עדכוני התקדמות (progress)
  • כשלא צריך תקשורת דו-כיוונית

שימוש ב-SSE בדפדפן

// EventSource API
const eventSource = new EventSource('/api/events');

eventSource.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('אירוע:', data);
});

eventSource.addEventListener('notification', (event) => {
  const data = JSON.parse(event.data);
  console.log('התראה:', data);
});

eventSource.addEventListener('error', () => {
  console.log('שגיאה - ינסה להתחבר מחדש אוטומטית');
});

// סגירה
eventSource.close();

SSE ב-Next.js API Route

// app/api/events/route.ts
export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      // שליחת אירוע כל 5 שניות
      const interval = setInterval(() => {
        const data = JSON.stringify({
          time: new Date().toISOString(),
          message: 'עדכון מהשרת',
        });

        controller.enqueue(
          encoder.encode(`data: ${data}\n\n`)
        );
      }, 5000);

      // שליחת אירוע מותאם אישית
      const notification = JSON.stringify({ title: 'שלום!' });
      controller.enqueue(
        encoder.encode(`event: notification\ndata: ${notification}\n\n`)
      );

      // ניקוי בסגירה
      setTimeout(() => {
        clearInterval(interval);
        controller.close();
      }, 60000); // סגירה אחרי דקה
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

Hook לשימוש ב-SSE

import { useEffect, useRef, useState, useCallback } from 'react';

function useSSE<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const sourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    const source = new EventSource(url);

    source.addEventListener('open', () => {
      setIsConnected(true);
      setError(null);
    });

    source.addEventListener('message', (event) => {
      try {
        const parsed = JSON.parse(event.data) as T;
        setData(parsed);
      } catch {
        setError('Failed to parse event data');
      }
    });

    source.addEventListener('error', () => {
      setIsConnected(false);
      setError('Connection lost');
    });

    sourceRef.current = source;

    return () => {
      source.close();
    };
  }, [url]);

  const close = useCallback(() => {
    sourceRef.current?.close();
    setIsConnected(false);
  }, []);

  return { data, error, isConnected, close };
}

// שימוש
function StockTicker() {
  const { data, isConnected } = useSSE<{ symbol: string; price: number }>(
    '/api/stocks'
  );

  return (
    <div>
      <p>{isConnected ? 'מחובר' : 'מנותק'}</p>
      {data && (
        <p>
          {data.symbol}: ${data.price}
        </p>
      )}
    </div>
  );
}

דפוסי זמן אמת - Real-time Patterns

חדרים - Rooms

// הצטרפות ועזיבת חדרים
function ChatRooms() {
  const { emit, on } = useSocket({ url: SOCKET_URL });
  const [currentRoom, setCurrentRoom] = useState('general');

  const joinRoom = (room: string) => {
    emit('room:leave', { room: currentRoom });
    emit('room:join', { room });
    setCurrentRoom(room);
  };

  return (
    <div>
      <button onClick={() => joinRoom('general')}>כללי</button>
      <button onClick={() => joinRoom('support')}>תמיכה</button>
      <button onClick={() => joinRoom('random')}>אקראי</button>
    </div>
  );
}

נוכחות - Presence

// מעקב אחרי משתמשים מחוברים
function OnlineUsers() {
  const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
  const { on } = useSocket({ url: SOCKET_URL });

  useEffect(() => {
    const unsub = on('presence:update', (data: unknown) => {
      const { users } = data as { users: string[] };
      setOnlineUsers(users);
    });

    return unsub;
  }, [on]);

  return (
    <div>
      <h3>משתמשים מחוברים ({onlineUsers.length})</h3>
      <ul>
        {onlineUsers.map((user) => (
          <li key={user}>
            <span className="online-dot" /> {user}
          </li>
        ))}
      </ul>
    </div>
  );
}

שידור - Broadcasting

// עדכון בזמן אמת לכל המשתמשים
function LiveNotifications() {
  const [notifications, setNotifications] = useState<string[]>([]);
  const { on } = useSocket({ url: SOCKET_URL });

  useEffect(() => {
    const unsub = on('broadcast:notification', (data: unknown) => {
      const { message } = data as { message: string };
      setNotifications((prev) => [message, ...prev].slice(0, 50));
    });

    return unsub;
  }, [on]);

  return (
    <div aria-live="polite">
      {notifications.map((notif, i) => (
        <p key={i}>{notif}</p>
      ))}
    </div>
  );
}

אסטרטגיות חיבור מחדש וטיפול בשגיאות

import { useEffect, useRef, useState, useCallback } from 'react';

interface ReconnectOptions {
  maxRetries: number;
  baseDelay: number;
  maxDelay: number;
}

function useReconnectingWebSocket(
  url: string,
  options: ReconnectOptions = { maxRetries: 10, baseDelay: 1000, maxDelay: 30000 }
) {
  const wsRef = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
  const [retryCount, setRetryCount] = useState(0);

  const connect = useCallback(() => {
    setStatus('connecting');
    const ws = new WebSocket(url);

    ws.addEventListener('open', () => {
      setStatus('connected');
      setRetryCount(0);
    });

    ws.addEventListener('close', (event) => {
      setStatus('disconnected');

      // אל תנסה מחדש אם נסגר בכוונה
      if (event.code === 1000) return;

      if (retryCount < options.maxRetries) {
        // Exponential backoff עם jitter
        const delay = Math.min(
          options.baseDelay * Math.pow(2, retryCount) + Math.random() * 1000,
          options.maxDelay
        );

        setTimeout(() => {
          setRetryCount((prev) => prev + 1);
          connect();
        }, delay);
      }
    });

    ws.addEventListener('error', () => {
      // שגיאה - ה-close event ידאג ל-reconnect
    });

    wsRef.current = ws;
  }, [url, retryCount, options]);

  useEffect(() => {
    connect();
    return () => {
      wsRef.current?.close(1000, 'Component unmounted');
    };
  }, []);

  const send = useCallback((data: unknown) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  return { send, status, retryCount };
}
// קומפוננטת סטטוס חיבור
function ConnectionStatus({ status, retryCount }: { status: string; retryCount: number }) {
  return (
    <div className={`connection-status ${status}`} role="status" aria-live="polite">
      {status === 'connected' && 'מחובר'}
      {status === 'connecting' && `מתחבר... (ניסיון ${retryCount + 1})`}
      {status === 'disconnected' && 'מנותק'}
    </div>
  );
}

השוואה - WebSocket מול SSE מול Polling

תכונה WebSocket SSE Polling
כיוון דו-כיווני שרת ללקוח לקוח לשרת
פרוטוקול ws:// / wss:// HTTP HTTP
חיבור מחדש ידני אוטומטי לא רלוונטי
תמיכה בדפדפנים מעולה מעולה מעולה
מורכבות בינונית נמוכה נמוכה
שימוש צ'אט, משחקים התראות, עדכונים דשבורדים פשוטים

סיכום

בשיעור זה למדנו על:

  • HTTP מול WebSocket - ההבדל בין request/response לחיבור מתמשך
  • WebSocket API - יצירת חיבורים, שליחה וקבלה של הודעות
  • Socket.io - ספריית client עם חדרים, אירועים ו-reconnection
  • צ'אט בזמן אמת - בניית קומפוננטת צ'אט מלאה עם הקלדה ונוכחות
  • SSE - אירועים חד-כיווניים מהשרת, פשוט ויעיל
  • דפוסי זמן אמת - חדרים, נוכחות ושידור
  • חיבור מחדש - Exponential backoff וטיפול בשגיאות

הבחירה בין WebSocket, SSE ו-Polling תלויה בצרכי האפליקציה - אם צריך תקשורת דו-כיוונית, WebSocket הוא הפתרון. לעדכונים חד-כיווניים, SSE פשוט ויעיל יותר.