fix: Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions during peer connection setup. Fix users unable to see or hear each other in voice channels due to stale server sockets, passive non-initiators, and race conditions during peer connection setup. Server: - Close stale WebSocket connections sharing the same oderId in handleIdentify instead of letting them linger up to 45s - Make user_joined/user_left broadcasts identity-aware so duplicate sockets don't produce phantom join/leave events - Include serverIds in user_left payload for multi-room presence - Simplify findUserByOderId now that stale sockets are cleaned up Client - signaling: - Add fallback offer system with 1s timer for missed user_joined races - Add non-initiator takeover after 5s when the initiator fails to send an offer (NON_INITIATOR_GIVE_UP_MS) - Scope peerServerMap per signaling URL to prevent cross-server collisions - Add socket identity guards on all signaling event handlers - Replace canReusePeerConnection with hasActivePeerConnection and isPeerConnectionNegotiating with extended grace periods Client - peer connections: - Extract replaceUnusablePeer helper to deduplicate stale peer replacement in offer and ICE handlers - Add stale connectionstatechange guard to ignore events from replaced RTCPeerConnection instances - Use deterministic initiator election in peer recovery reconnects - Track createdAt on PeerData for staleness detection Client - presence: - Add multi-room presence tracking via presenceServerIds on User - Replace clearUsers + individual userJoined with syncServerPresence for atomic server roster updates - Make userLeft handle partial server removal instead of full eviction Documentation: - Add server-side connection hygiene, non-initiator takeover, and stale peer replacement sections to the realtime README
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -24,6 +26,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
|
||||
const usersByOderId = new Map<string, ConnectedUser>();
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
usersByOderId.set(user.oderId, user);
|
||||
});
|
||||
|
||||
return Array.from(usersByOderId.values());
|
||||
}
|
||||
|
||||
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
|
||||
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
|
||||
connectionId !== excludeConnectionId
|
||||
&& user.oderId === oderId
|
||||
&& user.serverIds.has(serverId)
|
||||
&& user.ws.readyState === WebSocket.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
|
||||
const serverIds = new Set<string>();
|
||||
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.serverIds.forEach((serverId) => serverIds.add(serverId));
|
||||
});
|
||||
|
||||
return Array.from(serverIds);
|
||||
}
|
||||
|
||||
export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
const user = findUserByOderId(oderId);
|
||||
|
||||
@@ -33,5 +72,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
}
|
||||
|
||||
export function findUserByOderId(oderId: string) {
|
||||
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
|
||||
let match: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
|
||||
match = user;
|
||||
}
|
||||
});
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||
import {
|
||||
broadcastToServer,
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
|
||||
interface WsMessage {
|
||||
@@ -14,24 +20,53 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
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 = Array.from(connectedUsers.values())
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
user.oderId = String(message['oderId'] || connectionId);
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
|
||||
// Close stale connections from the same identity so offer routing
|
||||
// always targets the freshest socket (e.g. after page refresh).
|
||||
connectedUsers.forEach((existing, existingId) => {
|
||||
if (existingId !== connectionId && existing.oderId === newOderId) {
|
||||
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`);
|
||||
|
||||
try {
|
||||
existing.ws.close();
|
||||
} catch { /* already closing */ }
|
||||
|
||||
connectedUsers.delete(existingId);
|
||||
}
|
||||
});
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
}
|
||||
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = String(message['serverId']);
|
||||
const sid = readMessageId(message['serverId']);
|
||||
|
||||
if (!sid)
|
||||
return;
|
||||
@@ -48,16 +83,20 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
return;
|
||||
}
|
||||
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
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} (new=${isNew})`);
|
||||
console.log(
|
||||
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||
);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
|
||||
if (isNew) {
|
||||
if (isNewIdentityMembership) {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
@@ -68,7 +107,10 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
}
|
||||
|
||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const viewSid = String(message['serverId']);
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid)
|
||||
return;
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
@@ -78,7 +120,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
}
|
||||
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!leaveSid)
|
||||
return;
|
||||
@@ -90,17 +132,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
|
||||
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: Array.from(user.serverIds)
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
const targetUserId = String(message['targetUserId'] || '');
|
||||
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { connectedUsers } from './state';
|
||||
import { broadcastToServer } from './broadcast';
|
||||
import {
|
||||
broadcastToServer,
|
||||
getServerIdsForOderId,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
/** How often to ping all connected clients (ms). */
|
||||
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
|
||||
if (user) {
|
||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
|
||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||
|
||||
user.serverIds.forEach((sid) => {
|
||||
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: []
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user