Build Real-Time Applications

Real-Time Communication with WebSockets

5 min read

Project Goal: Build a real-time chat application with presence indicators using WebSockets.

Understanding Real-Time Options

Technology Use Case Pros Cons
WebSockets Bi-directional, low latency Full duplex, efficient Connection management
Server-Sent Events Server-to-client updates Simple, HTTP-based One-way only
Long Polling Fallback compatibility Works everywhere Inefficient
WebRTC P2P, media streaming Direct connection Complex setup

Project Setup Prompt

Build a real-time chat application:

## Tech Stack
- Next.js 15 with App Router
- Socket.io for WebSocket management
- Redis for pub/sub (multi-server support)
- Zustand for client state

## Features
1. Real-time messaging
2. User presence (online/offline/typing)
3. Message delivery status (sent/delivered/read)
4. Room/channel support
5. Reconnection handling

## Project Structure

/app /api/socket route.ts # Socket.io setup /chat page.tsx # Chat interface /lib /socket server.ts # Server socket handlers client.ts # Client socket hooks /redis client.ts # Redis connection /components /chat chat-room.tsx message-list.tsx presence-indicator.tsx

Socket.io Server Setup

// lib/socket/server.ts
import { Server as SocketIOServer } from 'socket.io';
import { Server as HTTPServer } from 'http';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

let io: SocketIOServer | null = null;

interface Message {
  id: string;
  roomId: string;
  userId: string;
  content: string;
  timestamp: Date;
  status: 'sent' | 'delivered' | 'read';
}

interface UserPresence {
  odIds: string;
  status: 'online' | 'away' | 'offline';
  lastSeen: Date;
  typing?: { roomId: string; timestamp: Date };
}

export async function initSocketServer(httpServer: HTTPServer) {
  if (io) return io;

  io = new SocketIOServer(httpServer, {
    cors: {
      origin: process.env.NEXT_PUBLIC_APP_URL,
      methods: ['GET', 'POST'],
    },
    transports: ['websocket', 'polling'],
  });

  // Redis adapter for horizontal scaling
  if (process.env.REDIS_URL) {
    const pubClient = createClient({ url: process.env.REDIS_URL });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    io.adapter(createAdapter(pubClient, subClient));
  }

  // Connection handling
  io.on('connection', (socket) => {
    console.log(`Client connected: ${socket.id}`);

    // User authentication
    socket.on('authenticate', async (userId: string) => {
      socket.data.userId = odIds;
      socket.join(`user:${odIds}`);

      // Update presence
      await updatePresence(odIds, 'online');
      io?.emit('presence:update', { userId, status: 'online' });
    });

    // Join room
    socket.on('room:join', async (roomId: string) => {
      socket.join(`room:${roomId}`);
      socket.to(`room:${roomId}`).emit('user:joined', {
        userId: socket.data.userId,
        roomId,
      });
    });

    // Leave room
    socket.on('room:leave', async (roomId: string) => {
      socket.leave(`room:${roomId}`);
      socket.to(`room:${roomId}`).emit('user:left', {
        userId: socket.data.userId,
        roomId,
      });
    });

    // Send message
    socket.on('message:send', async (data: {
      roomId: string;
      content: string;
    }) => {
      const message: Message = {
        id: crypto.randomUUID(),
        roomId: data.roomId,
        userId: socket.data.userId,
        content: data.content,
        timestamp: new Date(),
        status: 'sent',
      };

      // Broadcast to room
      io?.to(`room:${data.roomId}`).emit('message:new', message);

      // Confirm delivery to sender
      socket.emit('message:sent', { id: message.id });
    });

    // Typing indicator
    socket.on('typing:start', (roomId: string) => {
      socket.to(`room:${roomId}`).emit('typing:update', {
        userId: socket.data.userId,
        roomId,
        isTyping: true,
      });
    });

    socket.on('typing:stop', (roomId: string) => {
      socket.to(`room:${roomId}`).emit('typing:update', {
        userId: socket.data.userId,
        roomId,
        isTyping: false,
      });
    });

    // Message read receipt
    socket.on('message:read', (data: { messageId: string; roomId: string }) => {
      io?.to(`room:${data.roomId}`).emit('message:status', {
        messageId: data.messageId,
        status: 'read',
        readBy: socket.data.userId,
      });
    });

    // Disconnect
    socket.on('disconnect', async () => {
      if (socket.data.userId) {
        await updatePresence(socket.data.userId, 'offline');
        io?.emit('presence:update', {
          userId: socket.data.userId,
          status: 'offline',
        });
      }
    });
  });

  return io;
}

async function updatePresence(userId: string, status: string) {
  // Store in Redis or database
  console.log(`Presence update: ${userId} is ${status}`);
}

export function getIO() {
  return io;
}

Client Socket Hook

// lib/socket/client.ts
'use client';

import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { create } from 'zustand';

interface Message {
  id: string;
  roomId: string;
  userId: string;
  content: string;
  timestamp: Date;
  status: 'sent' | 'delivered' | 'read';
}

interface TypingUser {
  userId: string;
  roomId: string;
}

interface ChatState {
  messages: Message[];
  typingUsers: TypingUser[];
  onlineUsers: Set<string>;
  addMessage: (message: Message) => void;
  updateMessageStatus: (messageId: string, status: Message['status']) => void;
  setTyping: (userId: string, roomId: string, isTyping: boolean) => void;
  setUserOnline: (userId: string, isOnline: boolean) => void;
}

export const useChatStore = create<ChatState>((set) => ({
  messages: [],
  typingUsers: [],
  onlineUsers: new Set(),

  addMessage: (message) =>
    set((state) => ({
      messages: [...state.messages, message],
    })),

  updateMessageStatus: (messageId, status) =>
    set((state) => ({
      messages: state.messages.map((m) =>
        m.id === messageId ? { ...m, status } : m
      ),
    })),

  setTyping: (userId, roomId, isTyping) =>
    set((state) => ({
      typingUsers: isTyping
        ? [...state.typingUsers.filter((t) => t.userId !== userId), { userId, roomId }]
        : state.typingUsers.filter((t) => t.userId !== userId),
    })),

  setUserOnline: (userId, isOnline) =>
    set((state) => {
      const newSet = new Set(state.onlineUsers);
      if (isOnline) {
        newSet.add(userId);
      } else {
        newSet.delete(userId);
      }
      return { onlineUsers: newSet };
    }),
}));

export function useSocket(userId: string) {
  const socketRef = useRef<Socket | null>(null);
  const store = useChatStore();

  useEffect(() => {
    // Initialize socket connection
    socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
      transports: ['websocket', 'polling'],
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    const socket = socketRef.current;

    // Authenticate
    socket.emit('authenticate', userId);

    // Message handlers
    socket.on('message:new', (message: Message) => {
      store.addMessage(message);
    });

    socket.on('message:status', (data: { messageId: string; status: Message['status'] }) => {
      store.updateMessageStatus(data.messageId, data.status);
    });

    // Typing handlers
    socket.on('typing:update', (data: { userId: string; roomId: string; isTyping: boolean }) => {
      store.setTyping(data.userId, data.roomId, data.isTyping);
    });

    // Presence handlers
    socket.on('presence:update', (data: { userId: string; status: string }) => {
      store.setUserOnline(data.userId, data.status === 'online');
    });

    // Connection handlers
    socket.on('connect', () => {
      console.log('Socket connected');
    });

    socket.on('disconnect', () => {
      console.log('Socket disconnected');
    });

    socket.on('connect_error', (error) => {
      console.error('Socket connection error:', error);
    });

    return () => {
      socket.disconnect();
    };
  }, [userId, store]);

  const joinRoom = useCallback((roomId: string) => {
    socketRef.current?.emit('room:join', roomId);
  }, []);

  const leaveRoom = useCallback((roomId: string) => {
    socketRef.current?.emit('room:leave', roomId);
  }, []);

  const sendMessage = useCallback((roomId: string, content: string) => {
    socketRef.current?.emit('message:send', { roomId, content });
  }, []);

  const startTyping = useCallback((roomId: string) => {
    socketRef.current?.emit('typing:start', roomId);
  }, []);

  const stopTyping = useCallback((roomId: string) => {
    socketRef.current?.emit('typing:stop', roomId);
  }, []);

  const markAsRead = useCallback((messageId: string, roomId: string) => {
    socketRef.current?.emit('message:read', { messageId, roomId });
  }, []);

  return {
    socket: socketRef.current,
    joinRoom,
    leaveRoom,
    sendMessage,
    startTyping,
    stopTyping,
    markAsRead,
  };
}

Chat Room Component

// components/chat/chat-room.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { useSocket, useChatStore } from '@/lib/socket/client';
import { MessageList } from './message-list';
import { PresenceIndicator } from './presence-indicator';

interface ChatRoomProps {
  roomId: string;
  userId: string;
  userName: string;
}

export function ChatRoom({ roomId, userId, userName }: ChatRoomProps) {
  const [message, setMessage] = useState('');
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  const { joinRoom, leaveRoom, sendMessage, startTyping, stopTyping } =
    useSocket(userId);

  const { messages, typingUsers } = useChatStore();

  const roomMessages = messages.filter((m) => m.roomId === roomId);
  const roomTypingUsers = typingUsers.filter(
    (t) => t.roomId === roomId && t.userId !== userId
  );

  useEffect(() => {
    joinRoom(roomId);
    return () => leaveRoom(roomId);
  }, [roomId, joinRoom, leaveRoom]);

  const handleTyping = () => {
    startTyping(roomId);

    // Clear existing timeout
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }

    // Stop typing indicator after 2 seconds of inactivity
    typingTimeoutRef.current = setTimeout(() => {
      stopTyping(roomId);
    }, 2000);
  };

  const handleSend = () => {
    if (!message.trim()) return;

    sendMessage(roomId, message);
    setMessage('');
    stopTyping(roomId);

    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="flex h-full flex-col">
      {/* Header */}
      <div className="border-b p-4">
        <h2 className="font-semibold">Chat Room</h2>
        <PresenceIndicator roomId={roomId} />
      </div>

      {/* Messages */}
      <MessageList
        messages={roomMessages}
        currentUserId={userId}
      />

      {/* Typing indicator */}
      {roomTypingUsers.length > 0 && (
        <div className="px-4 py-2 text-sm text-muted-foreground">
          {roomTypingUsers.length === 1
            ? `Someone is typing...`
            : `${roomTypingUsers.length} people are typing...`}
        </div>
      )}

      {/* Input */}
      <div className="border-t p-4">
        <div className="flex gap-2">
          <input
            type="text"
            value={message}
            onChange={(e) => {
              setMessage(e.target.value);
              handleTyping();
            }}
            onKeyDown={handleKeyDown}
            placeholder="Type a message..."
            className="flex-1 rounded-lg border px-4 py-2"
          />
          <button
            onClick={handleSend}
            disabled={!message.trim()}
            className="rounded-lg bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
          >
            Send
          </button>
        </div>
      </div>
    </div>
  );
}

Message List Component

// components/chat/message-list.tsx
'use client';

import { useEffect, useRef } from 'react';
import { Check, CheckCheck } from 'lucide-react';
import { cn } from '@/lib/utils';

interface Message {
  id: string;
  userId: string;
  content: string;
  timestamp: Date;
  status: 'sent' | 'delivered' | 'read';
}

interface MessageListProps {
  messages: Message[];
  currentUserId: string;
}

export function MessageList({ messages, currentUserId }: MessageListProps) {
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages]);

  return (
    <div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
      <div className="space-y-4">
        {messages.map((message) => {
          const isOwn = message.userId === currentUserId;

          return (
            <div
              key={message.id}
              className={cn(
                'flex',
                isOwn ? 'justify-end' : 'justify-start'
              )}
            >
              <div
                className={cn(
                  'max-w-[70%] rounded-lg px-4 py-2',
                  isOwn
                    ? 'bg-primary text-primary-foreground'
                    : 'bg-muted'
                )}
              >
                <p>{message.content}</p>
                <div className="mt-1 flex items-center justify-end gap-1 text-xs opacity-70">
                  <span>
                    {new Date(message.timestamp).toLocaleTimeString([], {
                      hour: '2-digit',
                      minute: '2-digit',
                    })}
                  </span>
                  {isOwn && (
                    <span>
                      {message.status === 'read' ? (
                        <CheckCheck className="h-3 w-3 text-blue-400" />
                      ) : message.status === 'delivered' ? (
                        <CheckCheck className="h-3 w-3" />
                      ) : (
                        <Check className="h-3 w-3" />
                      )}
                    </span>
                  )}
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Key Takeaways

  1. Socket.io simplifies WebSocket management with fallbacks
  2. Redis adapter enables horizontal scaling across servers
  3. Zustand provides lightweight real-time state management
  4. Typing indicators need debouncing to avoid excessive events
  5. Read receipts improve user engagement visibility

الاتصال اللحظي مع WebSockets

هدف المشروع: بناء تطبيق دردشة لحظي مع مؤشرات الحضور باستخدام WebSockets.

فهم خيارات الوقت الفعلي

التقنية حالة الاستخدام الإيجابيات السلبيات
WebSockets ثنائي الاتجاه، تأخير منخفض duplex كامل، فعال إدارة الاتصال
Server-Sent Events تحديثات الخادم للعميل بسيط، مبني على HTTP اتجاه واحد فقط
Long Polling توافق احتياطي يعمل في كل مكان غير فعال
WebRTC P2P، بث الوسائط اتصال مباشر إعداد معقد

إعداد خادم Socket.io

// lib/socket/server.ts
import { Server as SocketIOServer } from 'socket.io';
import { Server as HTTPServer } from 'http';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

let io: SocketIOServer | null = null;

interface Message {
  id: string;
  roomId: string;
  userId: string;
  content: string;
  timestamp: Date;
  status: 'sent' | 'delivered' | 'read';
}

export async function initSocketServer(httpServer: HTTPServer) {
  if (io) return io;

  io = new SocketIOServer(httpServer, {
    cors: {
      origin: process.env.NEXT_PUBLIC_APP_URL,
      methods: ['GET', 'POST'],
    },
    transports: ['websocket', 'polling'],
  });

  // محول Redis للتوسع الأفقي
  if (process.env.REDIS_URL) {
    const pubClient = createClient({ url: process.env.REDIS_URL });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    io.adapter(createAdapter(pubClient, subClient));
  }

  // معالجة الاتصال
  io.on('connection', (socket) => {
    console.log(`عميل متصل: ${socket.id}`);

    // مصادقة المستخدم
    socket.on('authenticate', async (userId: string) => {
      socket.data.userId = userId;
      socket.join(`user:${userId}`);

      // تحديث الحضور
      await updatePresence(userId, 'online');
      io?.emit('presence:update', { userId, status: 'online' });
    });

    // الانضمام للغرفة
    socket.on('room:join', async (roomId: string) => {
      socket.join(`room:${roomId}`);
      socket.to(`room:${roomId}`).emit('user:joined', {
        userId: socket.data.userId,
        roomId,
      });
    });

    // إرسال رسالة
    socket.on('message:send', async (data: {
      roomId: string;
      content: string;
    }) => {
      const message: Message = {
        id: crypto.randomUUID(),
        roomId: data.roomId,
        userId: socket.data.userId,
        content: data.content,
        timestamp: new Date(),
        status: 'sent',
      };

      // البث للغرفة
      io?.to(`room:${data.roomId}`).emit('message:new', message);

      // تأكيد التسليم للمرسل
      socket.emit('message:sent', { id: message.id });
    });

    // مؤشر الكتابة
    socket.on('typing:start', (roomId: string) => {
      socket.to(`room:${roomId}`).emit('typing:update', {
        userId: socket.data.userId,
        roomId,
        isTyping: true,
      });
    });

    socket.on('typing:stop', (roomId: string) => {
      socket.to(`room:${roomId}`).emit('typing:update', {
        userId: socket.data.userId,
        roomId,
        isTyping: false,
      });
    });

    // قطع الاتصال
    socket.on('disconnect', async () => {
      if (socket.data.userId) {
        await updatePresence(socket.data.userId, 'offline');
        io?.emit('presence:update', {
          userId: socket.data.userId,
          status: 'offline',
        });
      }
    });
  });

  return io;
}

async function updatePresence(userId: string, status: string) {
  console.log(`تحديث الحضور: ${userId} هو ${status}`);
}

هوك Socket العميل

// lib/socket/client.ts
'use client';

import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { create } from 'zustand';

interface Message {
  id: string;
  roomId: string;
  userId: string;
  content: string;
  timestamp: Date;
  status: 'sent' | 'delivered' | 'read';
}

interface ChatState {
  messages: Message[];
  typingUsers: { userId: string; roomId: string }[];
  onlineUsers: Set<string>;
  addMessage: (message: Message) => void;
  updateMessageStatus: (messageId: string, status: Message['status']) => void;
  setTyping: (userId: string, roomId: string, isTyping: boolean) => void;
  setUserOnline: (userId: string, isOnline: boolean) => void;
}

export const useChatStore = create<ChatState>((set) => ({
  messages: [],
  typingUsers: [],
  onlineUsers: new Set(),

  addMessage: (message) =>
    set((state) => ({
      messages: [...state.messages, message],
    })),

  updateMessageStatus: (messageId, status) =>
    set((state) => ({
      messages: state.messages.map((m) =>
        m.id === messageId ? { ...m, status } : m
      ),
    })),

  setTyping: (userId, roomId, isTyping) =>
    set((state) => ({
      typingUsers: isTyping
        ? [...state.typingUsers.filter((t) => t.userId !== userId), { userId, roomId }]
        : state.typingUsers.filter((t) => t.userId !== userId),
    })),

  setUserOnline: (userId, isOnline) =>
    set((state) => {
      const newSet = new Set(state.onlineUsers);
      if (isOnline) {
        newSet.add(userId);
      } else {
        newSet.delete(userId);
      }
      return { onlineUsers: newSet };
    }),
}));

export function useSocket(userId: string) {
  const socketRef = useRef<Socket | null>(null);
  const store = useChatStore();

  useEffect(() => {
    socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
      transports: ['websocket', 'polling'],
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    const socket = socketRef.current;

    // المصادقة
    socket.emit('authenticate', userId);

    // معالجات الرسائل
    socket.on('message:new', (message: Message) => {
      store.addMessage(message);
    });

    // معالجات الحضور
    socket.on('presence:update', (data: { userId: string; status: string }) => {
      store.setUserOnline(data.userId, data.status === 'online');
    });

    return () => {
      socket.disconnect();
    };
  }, [userId, store]);

  const joinRoom = useCallback((roomId: string) => {
    socketRef.current?.emit('room:join', roomId);
  }, []);

  const sendMessage = useCallback((roomId: string, content: string) => {
    socketRef.current?.emit('message:send', { roomId, content });
  }, []);

  const startTyping = useCallback((roomId: string) => {
    socketRef.current?.emit('typing:start', roomId);
  }, []);

  const stopTyping = useCallback((roomId: string) => {
    socketRef.current?.emit('typing:stop', roomId);
  }, []);

  return {
    socket: socketRef.current,
    joinRoom,
    sendMessage,
    startTyping,
    stopTyping,
  };
}

النقاط الرئيسية

  1. Socket.io يبسط إدارة WebSocket مع بدائل احتياطية
  2. محول Redis يُمكّن التوسع الأفقي عبر الخوادم
  3. Zustand يوفر إدارة حالة خفيفة للوقت الفعلي
  4. مؤشرات الكتابة تحتاج تأخيراً لتجنب الأحداث المفرطة
  5. إيصالات القراءة تحسن رؤية تفاعل المستخدم

Quiz

Module 5: Build Real-Time Applications

Take Quiz