import type { WebSocket } from "ws"; import type { MeetingRole } from "./token"; export type Participant = { ws: WebSocket; participantId: string; clientId?: string; name: string; role: MeetingRole; }; type Room = { participants: Map; kickedParticipantIds: Set; // Keyed by the client-declared, localStorage-persisted clientId (see // token.ts) rather than the per-connection participantId, so a kick // survives the participant simply reconnecting (which always mints a // fresh participantId) — see isKicked below. kickedClientIds: Set; }; // In-memory only — this server process is the single source of truth for // signaling (no media is ever stored or relayed through it). If this app is // ever scaled to multiple instances, this state needs to move to a shared // store (e.g. Redis pub/sub) alongside a real SFU. const rooms = new Map(); function getOrCreateRoom(eventSlug: string): Room { let room = rooms.get(eventSlug); if (!room) { room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() }; rooms.set(eventSlug, room); } return room; } export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean { const room = rooms.get(eventSlug); if (!room) return false; if (room.kickedParticipantIds.has(participantId)) return true; return clientId ? room.kickedClientIds.has(clientId) : false; } export function joinRoom(eventSlug: string, participant: Participant): Participant[] { const room = getOrCreateRoom(eventSlug); const existing = [...room.participants.values()]; // Two connections claiming the same participantId (e.g. two tabs replaying // the same stored session) would otherwise silently overwrite the map // entry here and orphan the first socket — close it first so it doesn't // leak as an unreachable broadcast target. room.participants.get(participant.participantId)?.ws.close(4002, "replaced-by-new-connection"); room.participants.set(participant.participantId, participant); return existing; } export function leaveRoom(eventSlug: string, participantId: string) { const room = rooms.get(eventSlug); if (!room) return; room.participants.delete(participantId); if (room.participants.size === 0) rooms.delete(eventSlug); } export function kickFromRoom(eventSlug: string, participantId: string) { const room = rooms.get(eventSlug); if (!room) return; room.kickedParticipantIds.add(participantId); const target = room.participants.get(participantId); if (target?.clientId) room.kickedClientIds.add(target.clientId); room.participants.delete(participantId); } export function getParticipant(eventSlug: string, participantId: string): Participant | undefined { return rooms.get(eventSlug)?.participants.get(participantId); } export function getRoomParticipants(eventSlug: string): Participant[] { return [...(rooms.get(eventSlug)?.participants.values() ?? [])]; } export function broadcast( eventSlug: string, message: unknown, opts: { exclude?: string } = {}, ) { const room = rooms.get(eventSlug); if (!room) return; const data = JSON.stringify(message); for (const p of room.participants.values()) { if (opts.exclude && p.participantId === opts.exclude) continue; if (p.ws.readyState === p.ws.OPEN) p.ws.send(data); } }