// ── WebRTC Signaling Server ───────────────────────────────────────── // Provides real-time signaling for the CRM chat system using Socket.IO: // - JWT-based authentication middleware // - Online user presence tracking (online/offline) // - Peer-to-peer call signaling (offer/answer/ICE candidates) // - Multi-participant room-based WebRTC (join/leave/relay) // - Message deletion broadcast to conversation participants // // Clients authenticate via socket handshake auth token; unauthenticated // users can still join rooms as anonymous participants. import { createServer } from "http" import { Server } from "socket.io" import { SignJWT, jwtVerify } from "jose" import pg from "pg" // ── Configuration ───────────────────────────────────────────────── const PORT = process.env.SIGNALING_PORT || 3007 const rawSecret = process.env.JWT_SECRET if (!rawSecret) throw new Error("JWT_SECRET environment variable is required") const JWT_SECRET = new TextEncoder().encode(rawSecret) const DATABASE_URL = process.env.DATABASE_URL if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required") // ── Database pool ───────────────────────────────────────────────── const pool = new pg.Pool({ connectionString: DATABASE_URL }) // ── HTTP + Socket.IO server ─────────────────────────────────────── const httpServer = createServer() const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } }) // ── In-memory state ─────────────────────────────────────────────── // onlineUsers maps userId -> socketId for presence tracking. // rooms maps roomId -> array of { socketId, id, label } participants. const onlineUsers = new Map() const rooms = new Map() // ── Authentication middleware ───────────────────────────────────── // Verifies the JWT token from handshake auth. If valid, attaches // userId and role to the socket's data bag. Unauthenticated sockets // proceed with null user info. io.use((socket, next) => { const token = socket.handshake.auth?.token if (token) { jwtVerify(token, JWT_SECRET) .then(({ payload }) => { socket.data.userId = payload.userId socket.data.role = payload.role next() }) .catch(() => { socket.data.userId = null socket.data.role = null next() }) } else { socket.data.userId = null socket.data.role = null next() } }) // ── Connection handler ──────────────────────────────────────────── // Registers authenticated users as online, then wires up all // per-socket event listeners for signaling and presence. io.on("connection", (socket) => { const userId = socket.data.userId // Mark the user online and broadcast to other clients if (userId) { onlineUsers.set(userId, socket.id) socket.broadcast.emit("user:online", { userId }) } // ── Authenticated event handlers ────────────────────────────── // Only users with a valid JWT token may use these features. if (userId) { // user:lookup — looks up a user by phone number in the database. // Fires when the client searches for a contact to start a call. // Returns { found, user } or { found: false }. socket.on("user:lookup", async ({ phone }, callback) => { try { const result = await pool.query( `SELECT id, username, first_name, last_name, phone, avatar_url FROM users WHERE phone = $1 AND deleted_at IS NULL LIMIT 1`, [phone], ) if (result.rows.length > 0) { const user = result.rows[0] callback({ found: true, user: { id: user.id, username: user.username, firstName: user.first_name, lastName: user.last_name, phone: user.phone, avatar: user.avatar_url, online: onlineUsers.has(user.id), }, }) } else { callback({ found: false }) } } catch { callback({ found: false, error: "Lookup failed" }) } }) // call:offer — relays an SDP offer to the target user. // Fires when the caller initiates a WebRTC call. // callerInfo is fetched from the DB if not supplied. socket.on("call:offer", async ({ to, sdp, callerInfo }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { let info = callerInfo if (!info) { try { const result = await pool.query( `SELECT id, username, first_name, last_name, avatar_url FROM users WHERE id = $1`, [userId], ) if (result.rows.length > 0) { const u = result.rows[0] info = { id: u.id, username: u.username, firstName: u.first_name, lastName: u.last_name, avatar: u.avatar_url, } } } catch {} } io.to(targetSocketId).emit("call:incoming", { from: userId, sdp, callerInfo: info, }) } else { socket.emit("call:user-offline", { userId: to }) } }) // call:answer — relays an SDP answer back to the caller. // Fires when the callee accepts the incoming call. socket.on("call:answer", ({ to, sdp }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { io.to(targetSocketId).emit("call:answered", { from: userId, sdp }) } }) // call:ice-candidate — relays ICE candidates between peers. // Fires during connection negotiation for NAT traversal. socket.on("call:ice-candidate", ({ to, candidate }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate }) } }) // call:end — notifies the remote peer that the call ended. socket.on("call:end", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { io.to(targetSocketId).emit("call:ended", { from: userId }) } }) // call:reject — tells the caller the callee rejected the call. socket.on("call:reject", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { io.to(targetSocketId).emit("call:rejected", { from: userId }) } }) // call:busy — tells the caller the callee is on another call. socket.on("call:busy", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { io.to(targetSocketId).emit("call:busy", { from: userId }) } }) // message:deleted — broadcasts a deletion event to all other // participants in the conversation. Looks up participant IDs // from the database so only relevant clients are notified. socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => { try { const result = await pool.query( `SELECT cp.user_id FROM conversation_participants cp WHERE cp.conversation_id = $1 AND cp.user_id != $2`, [conversationId, senderId || userId], ) for (const row of result.rows) { const targetSocketId = onlineUsers.get(row.user_id) if (targetSocketId) { io.to(targetSocketId).emit("message:deleted", { conversationId, messageId }) } } console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`) } catch { console.warn(`[message:deleted] failed to broadcast msg=${messageId}`) } }) } // ── Room-based signaling (authenticated + anonymous) ────────── // These events support multi-participant rooms used for features // like screen sharing or group calls. Anonymous users are assigned // an "anon-" prefix ID. // room:join — adds the socket to a named room. When the first // participant joins they receive "room:waiting". When the second // joins the first is told to initiate an offer. Additional // participants are notified normally. socket.on("room:join", ({ roomId, displayName }) => { const participantId = userId || `anon-${socket.id}` const participantLabel = displayName || participantId if (!rooms.has(roomId)) { rooms.set(roomId, []) } const participants = rooms.get(roomId) participants.push({ socketId: socket.id, id: participantId, label: participantLabel }) socket.join(roomId) const list = participants.map(p => ({ id: p.id, label: p.label })) if (participants.length === 1) { socket.emit("room:waiting", { roomId, participants: list }) } else if (participants.length === 2) { io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list }) io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list }) } else { socket.emit("room:participant-joined", { roomId, participants: list }) socket.to(roomId).emit("room:participant-joined", { roomId, participants: list }) } }) // room:offer — relays an SDP offer to all other room participants. socket.on("room:offer", ({ roomId, sdp }) => { socket.to(roomId).emit("room:offer", { sdp }) }) // room:answer — relays an SDP answer to all other room participants. socket.on("room:answer", ({ roomId, sdp }) => { socket.to(roomId).emit("room:answer", { sdp }) }) // room:ice-candidate — relays ICE candidates to all other room participants. socket.on("room:ice-candidate", ({ roomId, candidate }) => { socket.to(roomId).emit("room:ice-candidate", { candidate }) }) // room:leave — removes the socket from a room. Cleans up the // in-memory room map and notifies remaining participants. Rooms // are fully deleted when empty. socket.on("room:leave", ({ roomId }) => { socket.leave(roomId) const participants = rooms.get(roomId) if (participants) { const idx = participants.findIndex(p => p.socketId === socket.id) if (idx !== -1) participants.splice(idx, 1) if (participants.length === 0) { rooms.delete(roomId) } else { const list = participants.map(p => ({ id: p.id, label: p.label })) io.to(roomId).emit("room:participant-left", { roomId, participants: list }) } } }) // disconnect — fires when the socket loses connection. Cleans up // the user from all rooms they were in, removes them from the // online users map, and broadcasts the offline event. socket.on("disconnect", () => { for (const [roomId, participants] of rooms) { const idx = participants.findIndex(p => p.socketId === socket.id) if (idx !== -1) { participants.splice(idx, 1) if (participants.length === 0) { rooms.delete(roomId) } else { const list = participants.map(p => ({ id: p.id, label: p.label })) io.to(roomId).emit("room:participant-left", { roomId, participants: list }) } } } if (userId) { onlineUsers.delete(userId) socket.broadcast.emit("user:offline", { userId }) } }) }) // ── Start ───────────────────────────────────────────────────────── httpServer.listen(PORT, () => { console.log(`[signaling] server running on port ${PORT}`) })