Skip to main content

Overview

The Horse Trust platform uses Socket.io for real-time bidirectional communication, primarily for the chat system and live notifications.

Socket.io Setup

Configured in index.ts with CORS and authentication:
import { Server as SocketServer, Socket } from "socket.io";
import http from "http";
import app from "./app";

const httpServer = http.createServer(app);

const io = new SocketServer(httpServer, {
  cors: {
    origin: (process.env.CORS_ORIGINS || "http://localhost:5173").split(","),
    credentials: true,
  },
});
Reference: index.ts:11-20

Authentication Middleware

Socket connections require JWT authentication:
import jwt, { JwtPayload } from "jsonwebtoken";

io.use((socket: Socket, next) => {
  const token = socket.handshake.auth?.token as string | undefined;
  if (!token) {
    return next(new Error("Authentication error: no token"));
  }
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
    (socket as Socket & { user: JwtPayload }).user = decoded;
    next();
  } catch {
    next(new Error("Authentication error: invalid token"));
  }
});
Reference: index.ts:22-35 Client Connection Example:
import { io } from "socket.io-client";

const socket = io("http://localhost:3000", {
  auth: {
    token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
});

Connection Handler

When a user connects, they automatically join their personal room:
io.on("connection", (socket: Socket) => {
  const user = (socket as Socket & { user: JwtPayload }).user;
  console.log(`🔌 Socket connected: userId=${user.userId}`);

  // Join a room per user (so we can target recipients)
  socket.join(`user:${user.userId}`);

  // Event handlers...

  socket.on("disconnect", () => {
    console.log(`🔌 Socket disconnected: userId=${user.userId}`);
  });
});
Reference: index.ts:38-123

Event Handlers

join_conversation

Join a conversation room to receive messages:
socket.on("join_conversation", (conversationId: string) => {
  socket.join(`conv:${conversationId}`);
  console.log(`  User ${user.userId} joined conv:${conversationId}`);
});
Reference: index.ts:46-49 Client Example:
socket.emit("join_conversation", "conversation_id_123");

send_message

Send a message in a conversation:
socket.on(
  "send_message",
  async (data: { conversation_id: string; text: string }, ack?: (res: unknown) => void) => {
    try {
      // Verify user is participant
      const conversation = await Conversation.findOne({
        _id: data.conversation_id,
        participants: user.userId,
      });

      if (!conversation) {
        if (ack) ack({ success: false, message: "Conversation not found" });
        return;
      }

      // Persist to DB
      const message = await Message.create({
        conversation_id: data.conversation_id,
        sender_id: user.userId,
        text: data.text,
        is_read: false,
      });

      // Update last_message snapshot
      await Conversation.findByIdAndUpdate(data.conversation_id, {
        last_message: {
          text: data.text,
          sender_id: user.userId,
          sent_at: message.sent_at,
          is_read: false,
        },
        updated_at: new Date(),
      });

      const populated = await message.populate("sender_id", "full_name profile_picture_url");

      // Emit to all in the conversation room
      io.to(`conv:${data.conversation_id}`).emit("new_message", populated);

      // Also notify recipient's personal room (for badge/notification)
      const recipientId = conversation.participants.find(
        (p) => p.toString() !== user.userId
      );
      if (recipientId) {
        io.to(`user:${recipientId}`).emit("message_notification", {
          conversation_id: data.conversation_id,
          sender: user.userId,
          preview: data.text.substring(0, 60),
        });
      }

      if (ack) ack({ success: true, data: populated });
    } catch (err) {
      console.error("Socket send_message error:", err);
      if (ack) ack({ success: false, message: "Error sending message" });
    }
  }
);
Reference: index.ts:52-109 Client Example with Acknowledgment:
socket.emit(
  "send_message",
  {
    conversation_id: "conv_123",
    text: "Hello, is this horse still available?"
  },
  (response) => {
    if (response.success) {
      console.log("Message sent:", response.data);
    } else {
      console.error("Error:", response.message);
    }
  }
);

typing

Indicates the user is typing:
socket.on("typing", (conversationId: string) => {
  socket.to(`conv:${conversationId}`).emit("user_typing", { userId: user.userId });
});
Reference: index.ts:112-114 Client Example:
// User starts typing
socket.emit("typing", "conversation_id_123");

// Listen for typing indicators
socket.on("user_typing", (data) => {
  console.log(`User ${data.userId} is typing...`);
});

stop_typing

Indicates the user stopped typing:
socket.on("stop_typing", (conversationId: string) => {
  socket.to(`conv:${conversationId}`).emit("user_stop_typing", { userId: user.userId });
});
Reference: index.ts:116-118 Client Example:
// User stops typing
socket.emit("stop_typing", "conversation_id_123");

// Listen for stop typing
socket.on("user_stop_typing", (data) => {
  console.log(`User ${data.userId} stopped typing`);
});

Room Structure

The socket implementation uses two types of rooms:

User Rooms

Format: user:{userId}
  • Users automatically join their personal room on connection
  • Used for personal notifications and badges
  • Example: user:507f1f77bcf86cd799439011

Conversation Rooms

Format: conv:{conversationId}
  • Users join when opening a conversation
  • Used for broadcasting messages to all participants
  • Example: conv:507f191e810c19729de860ea

Client Events to Listen For

new_message

Received when a new message is sent in a joined conversation:
socket.on("new_message", (message) => {
  console.log("New message:", message);
  // message structure:
  // {
  //   _id: "msg_id",
  //   conversation_id: "conv_id",
  //   sender_id: { _id: "user_id", full_name: "John Doe", profile_picture_url: "..." },
  //   text: "Message text",
  //   is_read: false,
  //   sent_at: "2024-01-15T10:30:00.000Z"
  // }
});
Reference: index.ts:89

message_notification

Received in the user’s personal room when a message is sent to them:
socket.on("message_notification", (notification) => {
  console.log("New message notification:", notification);
  // notification structure:
  // {
  //   conversation_id: "conv_id",
  //   sender: "user_id",
  //   preview: "Hello, is this horse still available?"
  // }
  
  // Show badge or notification UI
});
Reference: index.ts:96-100

user_typing

Received when another user in the conversation starts typing:
socket.on("user_typing", (data) => {
  console.log(`User ${data.userId} is typing...`);
  // Show typing indicator in UI
});
Reference: index.ts:113

user_stop_typing

Received when another user stops typing:
socket.on("user_stop_typing", (data) => {
  console.log(`User ${data.userId} stopped typing`);
  // Hide typing indicator in UI
});
Reference: index.ts:117

Complete Client Integration Example

import { io } from "socket.io-client";

const token = localStorage.getItem("jwt_token");

const socket = io("http://localhost:3000", {
  auth: { token }
});

// Handle connection
socket.on("connect", () => {
  console.log("Connected to server");
  
  // Join conversation room
  socket.emit("join_conversation", conversationId);
});

// Listen for new messages
socket.on("new_message", (message) => {
  addMessageToUI(message);
});

// Listen for notifications
socket.on("message_notification", (notification) => {
  showNotificationBadge(notification.conversation_id);
});

// Listen for typing indicators
socket.on("user_typing", (data) => {
  showTypingIndicator(data.userId);
});

socket.on("user_stop_typing", (data) => {
  hideTypingIndicator(data.userId);
});

// Send message
function sendMessage(text: string) {
  socket.emit(
    "send_message",
    { conversation_id: conversationId, text },
    (response) => {
      if (response.success) {
        console.log("Message sent successfully");
      } else {
        console.error("Failed to send message:", response.message);
      }
    }
  );
}

// Handle typing
let typingTimeout: NodeJS.Timeout;
function handleTyping() {
  socket.emit("typing", conversationId);
  
  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {
    socket.emit("stop_typing", conversationId);
  }, 1000);
}

// Handle disconnection
socket.on("disconnect", () => {
  console.log("Disconnected from server");
});

// Handle errors
socket.on("connect_error", (error) => {
  console.error("Connection error:", error.message);
});

Error Handling

Socket authentication errors:
socket.on("connect_error", (error) => {
  if (error.message === "Authentication error: no token") {
    // Redirect to login
  } else if (error.message === "Authentication error: invalid token") {
    // Token expired, refresh or redirect to login
  }
});

Build docs developers (and LLMs) love