import { connectedUsers } from './state'; import { ConnectedUser } from './types'; import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast'; import { authorizeWebSocketJoin } from '../services/server-access.service'; interface WsMessage { [key: string]: unknown; type: string; } function normalizeDisplayName(value: unknown, fallback = 'User'): string { const normalized = typeof value === 'string' ? value.trim() : ''; return normalized || fallback; } function normalizeDescription(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); return normalized || undefined; } function normalizeProfileUpdatedAt(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } function readMessageId(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); if (!normalized || normalized === 'undefined' || normalized === 'null') { return undefined; } return normalized; } /** Sends the current user list for a given server to a single connected user. */ function sendServerUsers(user: ConnectedUser, serverId: string): void { const users = getUniqueUsersInServer(serverId, user.oderId) .map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), description: cu.description, profileUpdatedAt: cu.profileUpdatedAt, status: cu.status ?? 'online' })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); } function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { const newOderId = readMessageId(message['oderId']) ?? connectionId; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const previousDisplayName = normalizeDisplayName(user.displayName); const previousDescription = user.description; const previousProfileUpdatedAt = user.profileUpdatedAt; // Close stale connections from the same identity AND the same connection // scope so offer routing always targets the freshest socket (e.g. after // page refresh). Connections with a *different* scope (= a different // signal URL that happens to route to this server) are left untouched so // multi-signal-URL setups don't trigger an eviction loop. connectedUsers.forEach((existing, existingId) => { if (existingId !== connectionId && existing.oderId === newOderId && existing.connectionScope === newScope) { console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`); try { existing.ws.close(); } catch { /* already closing */ } connectedUsers.delete(existingId); } }); user.oderId = newOderId; user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); if (Object.prototype.hasOwnProperty.call(message, 'description')) { user.description = normalizeDescription(message['description']); } if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) { user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']); } user.connectionScope = newScope; connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); if ( user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt ) { return; } for (const serverId of user.serverIds) { broadcastToServer(serverId, { type: 'user_joined', oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), description: user.description, profileUpdatedAt: user.profileUpdatedAt, status: user.status ?? 'online', serverId }, user.oderId); } } async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { const sid = readMessageId(message['serverId']); if (!sid) return; const authorization = await authorizeWebSocketJoin(sid, user.oderId); if (!authorization.allowed) { user.ws.send(JSON.stringify({ type: 'access_denied', serverId: sid, reason: authorization.reason })); return; } const isNewConnectionMembership = !user.serverIds.has(sid); const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId); user.serverIds.add(sid); user.viewedServerId = sid; connectedUsers.set(connectionId, user); console.log( `User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` + `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})` ); sendServerUsers(user, sid); if (isNewIdentityMembership) { broadcastToServer(sid, { type: 'user_joined', oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), description: user.description, profileUpdatedAt: user.profileUpdatedAt, status: user.status ?? 'online', serverId: sid }, user.oderId); } } function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { const viewSid = readMessageId(message['serverId']); if (!viewSid) return; user.viewedServerId = viewSid; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); sendServerUsers(user, viewSid); } function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId; if (!leaveSid) return; user.serverIds.delete(leaveSid); if (user.viewedServerId === leaveSid) user.viewedServerId = undefined; connectedUsers.set(connectionId, user); const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId); if (remainingServerIds.includes(leaveSid)) { return; } broadcastToServer(leaveSid, { type: 'user_left', oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), serverId: leaveSid, serverIds: remainingServerIds }, user.oderId); } function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { const targetUserId = readMessageId(message['targetUserId']) ?? ''; console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); const targetUser = findUserByOderId(targetUserId); if (targetUser) { targetUser.ws.send(JSON.stringify({ ...message, fromUserId: user.oderId })); console.log(`Successfully forwarded ${message.type} to ${targetUserId}`); } else { console.log( `Target user ${targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(cu => ({ oderId: cu.oderId, displayName: cu.displayName })) ); } } function handleChatMessage(user: ConnectedUser, message: WsMessage): void { const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; if (chatSid && user.serverIds.has(chatSid)) { broadcastToServer(chatSid, { type: 'chat_message', serverId: chatSid, message: message['message'], senderId: user.oderId, senderName: user.displayName, timestamp: Date.now() }); } } function handleTyping(user: ConnectedUser, message: WsMessage): void { const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; if (typingSid && user.serverIds.has(typingSid)) { broadcastToServer(typingSid, { type: 'user_typing', serverId: typingSid, channelId, oderId: user.oderId, displayName: user.displayName }, user.oderId); } } const VALID_STATUSES = new Set([ 'online', 'away', 'busy', 'offline' ]); function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void { const status = typeof message['status'] === 'string' ? message['status'] : undefined; if (!status || !VALID_STATUSES.has(status)) return; user.status = status as ConnectedUser['status']; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`); for (const serverId of user.serverIds) { broadcastToServer(serverId, { type: 'status_update', oderId: user.oderId, status }, user.oderId); } } export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise { const user = connectedUsers.get(connectionId); if (!user) return; switch (message.type) { case 'identify': handleIdentify(user, message, connectionId); break; case 'join_server': await handleJoinServer(user, message, connectionId); break; case 'view_server': handleViewServer(user, message, connectionId); break; case 'leave_server': handleLeaveServer(user, message, connectionId); break; case 'offer': case 'answer': case 'ice_candidate': forwardRtcMessage(user, message); break; case 'chat_message': handleChatMessage(user, message); break; case 'typing': handleTyping(user, message); break; case 'status_update': handleStatusUpdate(user, message, connectionId); break; default: console.log('Unknown message type:', message.type); } }