WebSockets مع Socket.io و Next.js: بناء تطبيق دردشة في الوقت الفعلي

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

التواصل في الوقت الفعلي، بدون تعقيد. Socket.io هي المكتبة الأكثر شعبية لـ WebSockets في JavaScript. بالاقتران مع Next.js، تتيح لك بناء تطبيقات تفاعلية — دردشة، إشعارات مباشرة، لوحات تعاونية — بتجربة مطور استثنائية.

ما ستتعلمه

بنهاية هذا البرنامج التعليمي، ستعرف كيف:

  • فهم بروتوكول WebSocket وكيف يختلف عن HTTP
  • إعداد Socket.io في مشروع Next.js مع TypeScript
  • بناء خادم WebSocket مخصص متكامل مع Next.js
  • تنفيذ دردشة في الوقت الفعلي مع غرف متعددة
  • إضافة مؤشرات الكتابة وحالة تواجد المستخدم
  • التعامل مع الأحداث المخصصة وبث الرسائل
  • نشر تطبيقك في بيئة الإنتاج مع إدارة إعادة الاتصال

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • خبرة مع React و Next.js (App Router)
  • معرفة أساسية بـ TypeScript (الأنواع، الواجهات، async/await)
  • محرر أكواد — يُنصح بـ VS Code أو Cursor
  • إلمام بمفاهيم العميل-الخادم

فهم WebSockets

HTTP مقابل WebSocket

يعمل بروتوكول HTTP بنموذج طلب-استجابة: يرسل العميل طلبًا، يستجيب الخادم، ويتم إغلاق الاتصال. للحصول على تحديثات، يجب على العميل إرسال طلبات جديدة (polling).

WebSockets تنشئ اتصالاً ثنائي الاتجاه ومستمرًا بين العميل والخادم. بمجرد فتح الاتصال، يمكن لكلا الطرفين إرسال رسائل في أي وقت دون عبء إنشاء اتصالات جديدة.

HTTP التقليدي:
عميل ← طلب ← خادم ← استجابة ← إغلاق الاتصال
عميل ← طلب ← خادم ← استجابة ← إغلاق الاتصال

WebSocket:
عميل ←→ اتصال مستمر ←→ خادم
       (رسائل في كلا الاتجاهين)

لماذا Socket.io؟

يضيف Socket.io طبقة فوق WebSockets الأصلية:

  • إعادة الاتصال التلقائي — يستأنف الاتصال بعد فقدان الشبكة
  • الغرف (rooms) — تجميع الاتصالات حسب القناة
  • تأكيدات الاستلام — تأكيد تسليم الرسائل
  • احتياطي HTTP — يعمل حتى عند حظر WebSockets
  • دعم البيانات الثنائية — إرسال الملفات والصور
  • مساحات الأسماء (namespaces) — فصل منطق الأعمال

الخطوة 1: تهيئة المشروع

أنشئ مشروع Next.js جديدًا مع TypeScript:

npx create-next-app@latest realtime-chat --typescript --tailwind --app --src-dir
cd realtime-chat

ثبّت Socket.io للخادم والعميل:

npm install socket.io socket.io-client

سيبدو هيكل مشروعك كالتالي:

realtime-chat/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── api/
│   ├── components/
│   ├── lib/
│   │   └── socket.ts
│   └── types/
│       └── chat.ts
├── server.ts          # خادم مخصص
├── package.json
└── tsconfig.json

الخطوة 2: تعريف أنواع TypeScript

أنشئ الأنواع المشتركة بين العميل والخادم. هذه إحدى مزايا TypeScript — أحداث Socket.io تكون مُنمّطة على كلا الجانبين.

// src/types/chat.ts
 
export interface User {
  id: string;
  username: string;
  color: string;
  joinedAt: Date;
}
 
export interface Message {
  id: string;
  userId: string;
  username: string;
  color: string;
  content: string;
  room: string;
  timestamp: Date;
}
 
export interface Room {
  name: string;
  users: User[];
  messageCount: number;
}
 
// أحداث الخادم إلى العميل
export interface ServerToClientEvents {
  "message:new": (message: Message) => void;
  "user:joined": (user: User, room: string) => void;
  "user:left": (userId: string, room: string) => void;
  "room:users": (users: User[]) => void;
  "room:list": (rooms: Room[]) => void;
  "typing:start": (userId: string, username: string) => void;
  "typing:stop": (userId: string) => void;
}
 
// أحداث العميل إلى الخادم
export interface ClientToServerEvents {
  "message:send": (
    content: string,
    room: string,
    callback: (success: boolean) => void
  ) => void;
  "room:join": (room: string, username: string) => void;
  "room:leave": (room: string) => void;
  "typing:start": (room: string) => void;
  "typing:stop": (room: string) => void;
}

الخطوة 3: إنشاء خادم WebSocket

لا يدعم Next.js WebSockets بشكل أصلي في خادم التطوير الخاص به. سننشئ خادمًا مخصصًا يغلّف Next.js ويضيف Socket.io.

// server.ts
 
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server } from "socket.io";
import type {
  ServerToClientEvents,
  ClientToServerEvents,
  User,
  Message,
  Room,
} from "./src/types/chat";
 
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
 
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
 
// الحالة في الذاكرة (استبدل بـ Redis في الإنتاج)
const rooms = new Map<string, Set<string>>();
const users = new Map<string, User>();
const messageHistory = new Map<string, Message[]>();
 
// الغرف الافتراضية
const DEFAULT_ROOMS = ["عام", "تقنية", "عشوائي"];
 
function generateId(): string {
  return Math.random().toString(36).substring(2, 15);
}
 
function getRandomColor(): string {
  const colors = [
    "#EF4444", "#F97316", "#EAB308", "#22C55E",
    "#06B6D4", "#3B82F6", "#8B5CF6", "#EC4899",
  ];
  return colors[Math.floor(Math.random() * colors.length)];
}
 
function getRoomData(roomName: string): Room {
  const roomUsers = rooms.get(roomName) || new Set();
  const roomUserList = Array.from(roomUsers)
    .map((id) => users.get(id))
    .filter(Boolean) as User[];
  const messages = messageHistory.get(roomName) || [];
  return {
    name: roomName,
    users: roomUserList,
    messageCount: messages.length,
  };
}
 
app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);
    handler(req, res, parsedUrl);
  });
 
  const io = new Server<ClientToServerEvents, ServerToClientEvents>(
    httpServer,
    {
      cors: {
        origin: dev ? "http://localhost:3000" : process.env.NEXT_PUBLIC_URL,
        methods: ["GET", "POST"],
      },
      pingTimeout: 60000,
      pingInterval: 25000,
    }
  );
 
  // تهيئة الغرف الافتراضية
  DEFAULT_ROOMS.forEach((room) => {
    rooms.set(room, new Set());
    messageHistory.set(room, []);
  });
 
  io.on("connection", (socket) => {
    console.log(`اتصال: ${socket.id}`);
 
    let currentUser: User | null = null;
    let currentRoom: string | null = null;
 
    // الانضمام إلى غرفة
    socket.on("room:join", (room: string, username: string) => {
      currentUser = {
        id: socket.id,
        username,
        color: getRandomColor(),
        joinedAt: new Date(),
      };
      users.set(socket.id, currentUser);
 
      // مغادرة الغرفة السابقة إذا لزم الأمر
      if (currentRoom) {
        socket.leave(currentRoom);
        rooms.get(currentRoom)?.delete(socket.id);
        io.to(currentRoom).emit("user:left", socket.id, currentRoom);
        io.to(currentRoom).emit(
          "room:users",
          Array.from(rooms.get(currentRoom) || [])
            .map((id) => users.get(id))
            .filter(Boolean) as User[]
        );
      }
 
      // الانضمام إلى الغرفة الجديدة
      currentRoom = room;
      socket.join(room);
 
      if (!rooms.has(room)) {
        rooms.set(room, new Set());
        messageHistory.set(room, []);
      }
      rooms.get(room)!.add(socket.id);
 
      // إبلاغ المستخدمين الآخرين
      socket.to(room).emit("user:joined", currentUser, room);
 
      // إرسال قائمة مستخدمي الغرفة
      const roomUsers = Array.from(rooms.get(room)!)
        .map((id) => users.get(id))
        .filter(Boolean) as User[];
      io.to(room).emit("room:users", roomUsers);
 
      // إرسال قائمة الغرف المحدثة
      const roomList = Array.from(rooms.keys()).map(getRoomData);
      io.emit("room:list", roomList);
    });
 
    // إرسال رسالة
    socket.on("message:send", (content, room, callback) => {
      if (!currentUser || !room) {
        callback(false);
        return;
      }
 
      const message: Message = {
        id: generateId(),
        userId: currentUser.id,
        username: currentUser.username,
        color: currentUser.color,
        content: content.trim(),
        room,
        timestamp: new Date(),
      };
 
      // حفظ في السجل (الاحتفاظ بآخر 100)
      const history = messageHistory.get(room) || [];
      history.push(message);
      if (history.length > 100) {
        history.shift();
      }
      messageHistory.set(room, history);
 
      // البث للجميع في الغرفة
      io.to(room).emit("message:new", message);
      callback(true);
    });
 
    // مؤشرات الكتابة
    socket.on("typing:start", (room) => {
      if (currentUser) {
        socket
          .to(room)
          .emit("typing:start", currentUser.id, currentUser.username);
      }
    });
 
    socket.on("typing:stop", (room) => {
      if (currentUser) {
        socket.to(room).emit("typing:stop", currentUser.id);
      }
    });
 
    // مغادرة غرفة
    socket.on("room:leave", (room) => {
      socket.leave(room);
      rooms.get(room)?.delete(socket.id);
      if (currentUser) {
        io.to(room).emit("user:left", currentUser.id, room);
      }
      currentRoom = null;
 
      const roomUsers = Array.from(rooms.get(room) || [])
        .map((id) => users.get(id))
        .filter(Boolean) as User[];
      io.to(room).emit("room:users", roomUsers);
    });
 
    // قطع الاتصال
    socket.on("disconnect", () => {
      console.log(`قطع الاتصال: ${socket.id}`);
 
      if (currentRoom && currentUser) {
        rooms.get(currentRoom)?.delete(socket.id);
        io.to(currentRoom).emit("user:left", socket.id, currentRoom);
 
        const roomUsers = Array.from(rooms.get(currentRoom) || [])
          .map((id) => users.get(id))
          .filter(Boolean) as User[];
        io.to(currentRoom).emit("room:users", roomUsers);
      }
 
      users.delete(socket.id);
 
      const roomList = Array.from(rooms.keys()).map(getRoomData);
      io.emit("room:list", roomList);
    });
  });
 
  httpServer.listen(port, () => {
    console.log(`> الخادم جاهز على http://${hostname}:${port}`);
  });
});

حدّث الأوامر في package.json:

{
  "scripts": {
    "dev": "tsx server.ts",
    "build": "next build",
    "start": "NODE_ENV=production tsx server.ts"
  }
}

ثبّت tsx لتشغيل خادم TypeScript:

npm install -D tsx

الخطوة 4: إعداد عميل Socket.io

أنشئ وحدة مساعدة لاتصال العميل:

// src/lib/socket.ts
 
import { io, Socket } from "socket.io-client";
import type {
  ServerToClientEvents,
  ClientToServerEvents,
} from "@/types/chat";
 
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
 
let socket: TypedSocket | null = null;
 
export function getSocket(): TypedSocket {
  if (!socket) {
    socket = io({
      autoConnect: false,
      reconnection: true,
      reconnectionAttempts: 10,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 5000,
    });
  }
  return socket;
}
 
export function connectSocket(): TypedSocket {
  const s = getSocket();
  if (!s.connected) {
    s.connect();
  }
  return s;
}
 
export function disconnectSocket(): void {
  if (socket?.connected) {
    socket.disconnect();
  }
}

الخطوة 5: إنشاء خطاف useSocket

خطاف React مخصص يغلّف كل منطق Socket.io ويجعله قابلاً لإعادة الاستخدام في مكوناتك:

// src/hooks/useSocket.ts
 
"use client";
 
import { useEffect, useState, useCallback, useRef } from "react";
import { connectSocket, disconnectSocket, getSocket } from "@/lib/socket";
import type { Message, User, Room } from "@/types/chat";
 
interface UseSocketReturn {
  isConnected: boolean;
  messages: Message[];
  users: User[];
  rooms: Room[];
  typingUsers: Map<string, string>;
  joinRoom: (room: string, username: string) => void;
  sendMessage: (content: string, room: string) => Promise<boolean>;
  startTyping: (room: string) => void;
  stopTyping: (room: string) => void;
}
 
export function useSocket(): UseSocketReturn {
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState<Message[]>([]);
  const [users, setUsers] = useState<User[]>([]);
  const [rooms, setRooms] = useState<Room[]>([]);
  const [typingUsers, setTypingUsers] = useState<Map<string, string>>(
    new Map()
  );
  const typingTimeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());
 
  useEffect(() => {
    const socket = connectSocket();
 
    function onConnect() {
      setIsConnected(true);
    }
 
    function onDisconnect() {
      setIsConnected(false);
    }
 
    function onNewMessage(message: Message) {
      setMessages((prev) => [...prev, message]);
    }
 
    function onUserJoined(user: User, room: string) {
      const systemMessage: Message = {
        id: `system-${Date.now()}`,
        userId: "system",
        username: "النظام",
        color: "#6B7280",
        content: `${user.username} انضم إلى الغرفة`,
        room,
        timestamp: new Date(),
      };
      setMessages((prev) => [...prev, systemMessage]);
    }
 
    function onUserLeft(userId: string, room: string) {
      const systemMessage: Message = {
        id: `system-${Date.now()}`,
        userId: "system",
        username: "النظام",
        color: "#6B7280",
        content: `مستخدم غادر الغرفة`,
        room,
        timestamp: new Date(),
      };
      setMessages((prev) => [...prev, systemMessage]);
    }
 
    function onRoomUsers(updatedUsers: User[]) {
      setUsers(updatedUsers);
    }
 
    function onRoomList(updatedRooms: Room[]) {
      setRooms(updatedRooms);
    }
 
    function onTypingStart(userId: string, username: string) {
      setTypingUsers((prev) => {
        const next = new Map(prev);
        next.set(userId, username);
        return next;
      });
 
      const existing = typingTimeouts.current.get(userId);
      if (existing) clearTimeout(existing);
      typingTimeouts.current.set(
        userId,
        setTimeout(() => {
          setTypingUsers((prev) => {
            const next = new Map(prev);
            next.delete(userId);
            return next;
          });
        }, 3000)
      );
    }
 
    function onTypingStop(userId: string) {
      setTypingUsers((prev) => {
        const next = new Map(prev);
        next.delete(userId);
        return next;
      });
    }
 
    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("message:new", onNewMessage);
    socket.on("user:joined", onUserJoined);
    socket.on("user:left", onUserLeft);
    socket.on("room:users", onRoomUsers);
    socket.on("room:list", onRoomList);
    socket.on("typing:start", onTypingStart);
    socket.on("typing:stop", onTypingStop);
 
    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("message:new", onNewMessage);
      socket.off("user:joined", onUserJoined);
      socket.off("user:left", onUserLeft);
      socket.off("room:users", onRoomUsers);
      socket.off("room:list", onRoomList);
      socket.off("typing:start", onTypingStart);
      socket.off("typing:stop", onTypingStop);
      disconnectSocket();
    };
  }, []);
 
  const joinRoom = useCallback((room: string, username: string) => {
    const socket = getSocket();
    setMessages([]);
    socket.emit("room:join", room, username);
  }, []);
 
  const sendMessage = useCallback(
    (content: string, room: string): Promise<boolean> => {
      return new Promise((resolve) => {
        const socket = getSocket();
        socket.emit("message:send", content, room, (success) => {
          resolve(success);
        });
      });
    },
    []
  );
 
  const startTyping = useCallback((room: string) => {
    const socket = getSocket();
    socket.emit("typing:start", room);
  }, []);
 
  const stopTyping = useCallback((room: string) => {
    const socket = getSocket();
    socket.emit("typing:stop", room);
  }, []);
 
  return {
    isConnected,
    messages,
    users,
    rooms,
    typingUsers,
    joinRoom,
    sendMessage,
    startTyping,
    stopTyping,
  };
}

الخطوة 6: بناء مكونات الدردشة

6.1 — نموذج الانضمام

// src/components/JoinForm.tsx
 
"use client";
 
import { useState } from "react";
 
interface JoinFormProps {
  onJoin: (username: string) => void;
}
 
export function JoinForm({ onJoin }: JoinFormProps) {
  const [username, setUsername] = useState("");
 
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = username.trim();
    if (trimmed.length >= 2 && trimmed.length <= 20) {
      onJoin(trimmed);
    }
  }
 
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-950">
      <form
        onSubmit={handleSubmit}
        className="w-full max-w-sm rounded-2xl bg-gray-900 p-8 shadow-xl"
      >
        <h1 className="mb-6 text-center text-2xl font-bold text-white">
          انضم إلى الدردشة
        </h1>
        <input
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="اسم المستخدم"
          maxLength={20}
          className="mb-4 w-full rounded-lg bg-gray-800 px-4 py-3 text-white
                     placeholder-gray-500 outline-none ring-1 ring-gray-700
                     focus:ring-blue-500"
          autoFocus
        />
        <button
          type="submit"
          disabled={username.trim().length < 2}
          className="w-full rounded-lg bg-blue-600 py-3 font-semibold
                     text-white transition hover:bg-blue-500
                     disabled:opacity-40 disabled:cursor-not-allowed"
        >
          دخول
        </button>
      </form>
    </div>
  );
}

6.2 — قائمة الرسائل

// src/components/MessageList.tsx
 
"use client";
 
import { useEffect, useRef } from "react";
import type { Message } from "@/types/chat";
 
interface MessageListProps {
  messages: Message[];
  currentUserId: string;
}
 
export function MessageList({ messages, currentUserId }: MessageListProps) {
  const bottomRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
 
  return (
    <div className="flex-1 overflow-y-auto p-4 space-y-3">
      {messages.map((msg) => {
        const isSystem = msg.userId === "system";
        const isOwn = msg.userId === currentUserId;
 
        if (isSystem) {
          return (
            <div key={msg.id} className="text-center text-sm text-gray-500">
              {msg.content}
            </div>
          );
        }
 
        return (
          <div
            key={msg.id}
            className={`flex ${isOwn ? "justify-end" : "justify-start"}`}
          >
            <div
              className={`max-w-[70%] rounded-2xl px-4 py-2 ${
                isOwn
                  ? "bg-blue-600 text-white"
                  : "bg-gray-800 text-gray-100"
              }`}
            >
              {!isOwn && (
                <div
                  className="text-xs font-semibold mb-1"
                  style={{ color: msg.color }}
                >
                  {msg.username}
                </div>
              )}
              <p className="text-sm leading-relaxed">{msg.content}</p>
              <div
                className={`text-xs mt-1 ${
                  isOwn ? "text-blue-200" : "text-gray-500"
                }`}
              >
                {new Date(msg.timestamp).toLocaleTimeString("ar-SA", {
                  hour: "2-digit",
                  minute: "2-digit",
                })}
              </div>
            </div>
          </div>
        );
      })}
      <div ref={bottomRef} />
    </div>
  );
}

6.3 — حقل الإدخال مع مؤشر الكتابة

// src/components/MessageInput.tsx
 
"use client";
 
import { useState, useRef, useCallback } from "react";
 
interface MessageInputProps {
  onSend: (content: string) => Promise<boolean>;
  onTypingStart: () => void;
  onTypingStop: () => void;
  typingUsers: Map<string, string>;
  disabled?: boolean;
}
 
export function MessageInput({
  onSend,
  onTypingStart,
  onTypingStop,
  typingUsers,
  disabled,
}: MessageInputProps) {
  const [content, setContent] = useState("");
  const [sending, setSending] = useState(false);
  const typingTimer = useRef<NodeJS.Timeout | null>(null);
  const isTyping = useRef(false);
 
  const handleTyping = useCallback(() => {
    if (!isTyping.current) {
      isTyping.current = true;
      onTypingStart();
    }
 
    if (typingTimer.current) {
      clearTimeout(typingTimer.current);
    }
 
    typingTimer.current = setTimeout(() => {
      isTyping.current = false;
      onTypingStop();
    }, 2000);
  }, [onTypingStart, onTypingStop]);
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = content.trim();
    if (!trimmed || sending) return;
 
    setSending(true);
    isTyping.current = false;
    onTypingStop();
 
    const success = await onSend(trimmed);
    if (success) {
      setContent("");
    }
    setSending(false);
  }
 
  const typingNames = Array.from(typingUsers.values());
  let typingText = "";
  if (typingNames.length === 1) {
    typingText = `${typingNames[0]} يكتب...`;
  } else if (typingNames.length === 2) {
    typingText = `${typingNames[0]} و ${typingNames[1]} يكتبان...`;
  } else if (typingNames.length > 2) {
    typingText = `${typingNames.length} أشخاص يكتبون...`;
  }
 
  return (
    <div className="border-t border-gray-800 bg-gray-900 p-4">
      {typingText && (
        <div className="mb-2 text-xs text-gray-400 animate-pulse">
          {typingText}
        </div>
      )}
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={content}
          onChange={(e) => {
            setContent(e.target.value);
            handleTyping();
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter" && !e.shiftKey) {
              handleSubmit(e);
            }
          }}
          placeholder="اكتب رسالتك..."
          disabled={disabled}
          className="flex-1 rounded-lg bg-gray-800 px-4 py-3 text-white
                     placeholder-gray-500 outline-none ring-1 ring-gray-700
                     focus:ring-blue-500 disabled:opacity-50"
          autoFocus
        />
        <button
          type="submit"
          disabled={!content.trim() || sending || disabled}
          className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
                     text-white transition hover:bg-blue-500
                     disabled:opacity-40 disabled:cursor-not-allowed"
        >
          إرسال
        </button>
      </form>
    </div>
  );
}

6.4 — الشريط الجانبي للغرف والمستخدمين

// src/components/Sidebar.tsx
 
"use client";
 
import type { Room, User } from "@/types/chat";
 
interface SidebarProps {
  rooms: Room[];
  currentRoom: string;
  users: User[];
  isConnected: boolean;
  onRoomSelect: (room: string) => void;
}
 
export function Sidebar({
  rooms,
  currentRoom,
  users,
  isConnected,
  onRoomSelect,
}: SidebarProps) {
  return (
    <aside className="flex w-64 flex-col border-l border-gray-800 bg-gray-900">
      {/* حالة الاتصال */}
      <div className="flex items-center gap-2 border-b border-gray-800 p-4">
        <div
          className={`h-2.5 w-2.5 rounded-full ${
            isConnected ? "bg-green-500" : "bg-red-500"
          }`}
        />
        <span className="text-sm text-gray-400">
          {isConnected ? "متصل" : "غير متصل"}
        </span>
      </div>
 
      {/* قائمة الغرف */}
      <div className="flex-1 overflow-y-auto p-3">
        <h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
          الغرف
        </h2>
        <ul className="space-y-1">
          {rooms.map((room) => (
            <li key={room.name}>
              <button
                onClick={() => onRoomSelect(room.name)}
                className={`w-full rounded-lg px-3 py-2 text-right text-sm
                  transition ${
                    currentRoom === room.name
                      ? "bg-blue-600/20 text-blue-400"
                      : "text-gray-300 hover:bg-gray-800"
                  }`}
              >
                <span className="ml-1">#</span>
                {room.name}
                <span className="mr-auto text-xs text-gray-600">
                  {" "}
                  ({room.users.length})
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>
 
      {/* المستخدمون المتصلون */}
      <div className="border-t border-gray-800 p-3">
        <h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
          متصلون ({users.length})
        </h2>
        <ul className="space-y-1">
          {users.map((user) => (
            <li key={user.id} className="flex items-center gap-2 px-2 py-1">
              <div
                className="h-2 w-2 rounded-full"
                style={{ backgroundColor: user.color }}
              />
              <span className="text-sm text-gray-300">{user.username}</span>
            </li>
          ))}
        </ul>
      </div>
    </aside>
  );
}

الخطوة 7: تجميع الصفحة الرئيسية

// src/app/page.tsx
 
"use client";
 
import { useState, useCallback } from "react";
import { useSocket } from "@/hooks/useSocket";
import { JoinForm } from "@/components/JoinForm";
import { Sidebar } from "@/components/Sidebar";
import { MessageList } from "@/components/MessageList";
import { MessageInput } from "@/components/MessageInput";
import { getSocket } from "@/lib/socket";
 
const DEFAULT_ROOM = "عام";
 
export default function ChatPage() {
  const [username, setUsername] = useState<string | null>(null);
  const [currentRoom, setCurrentRoom] = useState(DEFAULT_ROOM);
 
  const {
    isConnected,
    messages,
    users,
    rooms,
    typingUsers,
    joinRoom,
    sendMessage,
    startTyping,
    stopTyping,
  } = useSocket();
 
  const handleJoin = useCallback(
    (name: string) => {
      setUsername(name);
      joinRoom(DEFAULT_ROOM, name);
    },
    [joinRoom]
  );
 
  const handleRoomSelect = useCallback(
    (room: string) => {
      if (room !== currentRoom && username) {
        setCurrentRoom(room);
        joinRoom(room, username);
      }
    },
    [currentRoom, username, joinRoom]
  );
 
  const handleSend = useCallback(
    (content: string) => sendMessage(content, currentRoom),
    [sendMessage, currentRoom]
  );
 
  const handleTypingStart = useCallback(
    () => startTyping(currentRoom),
    [startTyping, currentRoom]
  );
 
  const handleTypingStop = useCallback(
    () => stopTyping(currentRoom),
    [stopTyping, currentRoom]
  );
 
  if (!username) {
    return <JoinForm onJoin={handleJoin} />;
  }
 
  const socketId = getSocket().id || "";
 
  return (
    <div className="flex h-screen bg-gray-950 text-white">
      <Sidebar
        rooms={rooms}
        currentRoom={currentRoom}
        users={users}
        isConnected={isConnected}
        onRoomSelect={handleRoomSelect}
      />
      <main className="flex flex-1 flex-col">
        <header className="flex items-center border-b border-gray-800 px-6 py-4">
          <h1 className="text-lg font-semibold">
            <span className="text-gray-500 ml-1">#</span>
            {currentRoom}
          </h1>
          <span className="mr-3 text-sm text-gray-500">
            {users.length} عضو
          </span>
        </header>
 
        <MessageList messages={messages} currentUserId={socketId} />
 
        <MessageInput
          onSend={handleSend}
          onTypingStart={handleTypingStart}
          onTypingStop={handleTypingStop}
          typingUsers={typingUsers}
          disabled={!isConnected}
        />
      </main>
    </div>
  );
}

الخطوة 8: الاختبار محليًا

شغّل خادم التطوير:

npm run dev

افتح تبويبين على http://localhost:3000:

  1. في التبويب الأول، أدخل اسمًا مستعارًا (مثل: "أحمد") وانضم إلى الدردشة
  2. في التبويب الثاني، أدخل اسمًا آخر (مثل: "سارة")
  3. أرسل رسائل — تظهر فورًا في كلا التبويبين
  4. ابدأ بالكتابة — يظهر مؤشر الكتابة في التبويب الآخر
  5. غيّر الغرفة — تتحدث قائمة المستخدمين المتصلين

الخطوة 9: تحسينات الإنتاج

9.1 — إعادة اتصال قوية

يتعامل Socket.io مع إعادة الاتصال تلقائيًا، لكن يمكنك ضبط السلوك في الخطاف:

// إضافة في useSocket.ts - داخل useEffect
 
socket.on("connect_error", (error) => {
  console.error("خطأ في الاتصال:", error.message);
});
 
socket.io.on("reconnect", (attempt) => {
  console.log(`إعادة الاتصال بعد ${attempt} محاولة`);
  // إعادة الانضمام إلى الغرفة بعد إعادة الاتصال
  if (currentRoom && username) {
    socket.emit("room:join", currentRoom, username);
  }
});
 
socket.io.on("reconnect_failed", () => {
  console.error("فشل في إعادة الاتصال");
});

9.2 — التوسع مع Redis للنشر متعدد الخوادم

في الإنتاج مع عدة نسخ من الخادم، يتصل WebSockets بنسخة واحدة فقط. حزمة @socket.io/redis-adapter تزامن الأحداث بين جميع النسخ:

npm install @socket.io/redis-adapter ioredis
// server.ts - إضافة محول Redis
 
import { createClient } from "ioredis";
import { createAdapter } from "@socket.io/redis-adapter";
 
const pubClient = new createClient({ host: "localhost", port: 6379 });
const subClient = pubClient.duplicate();
 
io.adapter(createAdapter(pubClient, subClient));

مع هذا المحول، رسالة مرسلة إلى النسخة A تُنقل تلقائيًا إلى العملاء المتصلين بالنسخة B.

9.3 — تحديد معدل الإرسال

احمِ خادمك من إساءة الاستخدام بتحديد عدد الرسائل في الثانية:

// إضافة في معالجة الـ socket من جهة الخادم
 
const rateLimits = new Map<string, number[]>();
 
function isRateLimited(socketId: string): boolean {
  const now = Date.now();
  const timestamps = rateLimits.get(socketId) || [];
 
  // الاحتفاظ فقط بالرسائل من آخر 10 ثوانٍ
  const recent = timestamps.filter((t) => now - t < 10000);
  rateLimits.set(socketId, recent);
 
  if (recent.length >= 10) {
    return true; // 10 رسائل كحد أقصى كل 10 ثوانٍ
  }
 
  recent.push(now);
  return false;
}
 
// داخل معالج "message:send"
socket.on("message:send", (content, room, callback) => {
  if (isRateLimited(socket.id)) {
    callback(false);
    return;
  }
  // ... باقي الكود
});

الخطوة 10: النشر مع Docker

Dockerfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/server.ts ./
COPY --from=builder /app/src/types ./src/types
COPY --from=builder /app/tsconfig.json ./
 
EXPOSE 3000
CMD ["npx", "tsx", "server.ts"]

docker-compose.yml

version: "3.8"
services:
  chat:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_URL=https://chat.example.com
    restart: unless-stopped
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
 
volumes:
  redis_data:

شغّل بـ:

docker compose up -d

استكشاف الأخطاء

الرسائل لا تُستقبل

  1. تأكد أن الخادم المخصص يعمل (وليس next dev مباشرة)
  2. افتح أدوات المطور وتحقق من تبويب الشبكة — يجب أن يكون اتصال WebSocket مرئيًا
  3. تحقق من سجلات الخادم للبحث عن أخطاء الاتصال

خطأ CORS في الإنتاج

تأكد أن NEXT_PUBLIC_URL يطابق نطاق تطبيقك تمامًا. البروتوكول (https://) والمنفذ مهمان.

مؤشرات الكتابة تبقى عالقة

مهلة الـ 3 ثوانٍ في الخطاف من جهة العميل يجب أن تنظف المؤشرات. إذا استمرت المشكلة، تحقق من أن أحداث typing:stop تُبث عند قطع الاتصال.

زيادة استهلاك الذاكرة في الإنتاج

الحالة في الذاكرة (Map) في خادمنا لا تناسب الإنتاج على نطاق واسع. استخدم Redis لتخزين جلسات المستخدمين وسجل الرسائل.


الخطوات التالية

  • المصادقة — دمج Better Auth لحسابات المستخدمين
  • الاستمرارية — تخزين الرسائل في PostgreSQL مع Drizzle ORM
  • الإشعارات الفورية — إضافة إشعارات المتصفح للرسائل المستلمة خارج التركيز
  • مشاركة الملفات — استخدام إمكانيات Socket.io الثنائية لإرسال الصور
  • الرسائل الخاصة — تنفيذ نظام رسائل مباشرة مع غرف خاصة
  • التشفير من طرف إلى طرف — إضافة طبقة أمان مع Web Crypto API

الخلاصة

لقد بنيت تطبيق دردشة كاملًا في الوقت الفعلي يتضمن:

  • خادم WebSocket مخصص متكامل مع Next.js باستخدام Socket.io
  • أحداث مُنمّطة بفضل TypeScript على كلا الجانبين
  • غرف دردشة مع إدارة الحضور
  • مؤشرات كتابة في الوقت الفعلي
  • استراتيجيات الإنتاج: إعادة الاتصال، Redis، تحديد معدل الإرسال، و Docker

تفتح WebSockets عالمًا من الإمكانيات بعيدًا عن الدردشة: لوحات تعاونية، ألعاب متعددة اللاعبين، إشعارات مباشرة، محررات أكواد مشتركة. يوفر لك Socket.io الأساس المتين لبناء كل هذا بواجهة برمجة أنيقة وإدارة قوية للاتصالات.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء واجهات REST API جاهزة للإنتاج باستخدام FastAPI و PostgreSQL و Docker.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.

30 د قراءة·

بناء روبوت دردشة ذكاء اصطناعي محلي باستخدام Ollama و Next.js: الدليل الشامل

ابنِ روبوت دردشة ذكاء اصطناعي خاص يعمل بالكامل على جهازك المحلي باستخدام Ollama و Next.js. يغطي هذا الدليل العملي التثبيت والبث المباشر واختيار النماذج وبناء واجهة دردشة جاهزة للإنتاج — كل ذلك دون إرسال بياناتك إلى السحابة.

25 د قراءة·