Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_LEAVE_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
} from '../realtime.constants';
|
||||
import { ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||
import { SignalingTransportHandler } from './signaling-transport-handler';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
|
||||
interface ServerMembershipSignalingHandlerDependencies<TMessage> {
|
||||
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
|
||||
signalingTransport: SignalingTransportHandler<TMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getActiveServerId(): string | null;
|
||||
isVoiceConnected(): boolean;
|
||||
runFullCleanup(): void;
|
||||
}
|
||||
|
||||
export class ServerMembershipSignalingHandler<TMessage> {
|
||||
constructor(
|
||||
private readonly dependencies: ServerMembershipSignalingHandlerDependencies<TMessage>
|
||||
) {}
|
||||
|
||||
getCurrentSignalingUrl(): string | null {
|
||||
return this.dependencies.signalingTransport.getCurrentSignalingUrl(this.dependencies.getActiveServerId());
|
||||
}
|
||||
|
||||
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
|
||||
const resolvedSignalUrl = this.resolveSignalUrl(roomId, signalUrl);
|
||||
|
||||
if (!resolvedSignalUrl) {
|
||||
this.dependencies.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId });
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.signalingCoordinator.setServerSignalUrl(roomId, resolvedSignalUrl);
|
||||
this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, {
|
||||
serverId: roomId,
|
||||
userId
|
||||
});
|
||||
|
||||
this.dependencies.signalingCoordinator.addJoinedServer(resolvedSignalUrl, roomId);
|
||||
|
||||
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: roomId
|
||||
});
|
||||
}
|
||||
|
||||
switchServer(serverId: string, userId: string, signalUrl?: string): void {
|
||||
const resolvedSignalUrl = this.resolveSignalUrl(serverId, signalUrl);
|
||||
|
||||
if (!resolvedSignalUrl) {
|
||||
this.dependencies.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId });
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.signalingCoordinator.setServerSignalUrl(serverId, resolvedSignalUrl);
|
||||
this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, {
|
||||
serverId,
|
||||
userId
|
||||
});
|
||||
|
||||
const memberServerIds = this.dependencies.signalingCoordinator.getMemberServerIdsForSignalUrl(resolvedSignalUrl);
|
||||
|
||||
if (memberServerIds.has(serverId)) {
|
||||
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||
type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId
|
||||
});
|
||||
|
||||
this.dependencies.logger.info('Viewed server (already joined)', {
|
||||
serverId,
|
||||
signalUrl: resolvedSignalUrl,
|
||||
userId,
|
||||
voiceConnected: this.dependencies.isVoiceConnected()
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.signalingCoordinator.addJoinedServer(resolvedSignalUrl, serverId);
|
||||
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId
|
||||
});
|
||||
|
||||
this.dependencies.logger.info('Joined new server via switch', {
|
||||
serverId,
|
||||
signalUrl: resolvedSignalUrl,
|
||||
userId,
|
||||
voiceConnected: this.dependencies.isVoiceConnected()
|
||||
});
|
||||
}
|
||||
|
||||
leaveRoom(serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.leaveSingleRoom(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { signalUrl, serverIds } of this.dependencies.signalingCoordinator.getJoinedServerEntries()) {
|
||||
for (const joinedServerId of serverIds) {
|
||||
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(signalUrl, {
|
||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId: joinedServerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.dependencies.signalingCoordinator.clearJoinedServers();
|
||||
this.dependencies.runFullCleanup();
|
||||
}
|
||||
|
||||
private leaveSingleRoom(serverId: string): void {
|
||||
const resolvedSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId);
|
||||
|
||||
if (resolvedSignalUrl) {
|
||||
this.dependencies.signalingCoordinator.removeJoinedServer(resolvedSignalUrl, serverId);
|
||||
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId
|
||||
});
|
||||
} else {
|
||||
this.dependencies.signalingTransport.sendRawMessage({
|
||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId
|
||||
});
|
||||
|
||||
this.dependencies.signalingCoordinator.removeJoinedServerEverywhere(serverId);
|
||||
}
|
||||
|
||||
this.dependencies.signalingCoordinator.deleteServerSignalUrl(serverId);
|
||||
this.dependencies.logger.info('Left server', { serverId });
|
||||
|
||||
if (this.dependencies.signalingCoordinator.getJoinedServerCount() === 0) {
|
||||
this.dependencies.runFullCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSignalUrl(serverId: string, signalUrl?: string): string | null {
|
||||
return signalUrl
|
||||
?? this.dependencies.signalingCoordinator.getServerSignalUrl(serverId)
|
||||
?? this.getCurrentSignalingUrl();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Subscription } from 'rxjs';
|
||||
import { JoinedServerInfo } from '../realtime.types';
|
||||
import { SignalingManager } from './signaling.manager';
|
||||
|
||||
export interface ConnectedSignalingManager {
|
||||
signalUrl: string;
|
||||
manager: SignalingManager;
|
||||
}
|
||||
|
||||
export interface ServerSignalingCoordinatorCallbacks<TMessage> {
|
||||
createManager(
|
||||
signalUrl: string,
|
||||
getLastJoinedServer: () => JoinedServerInfo | null,
|
||||
getMemberServerIds: () => ReadonlySet<string>
|
||||
): SignalingManager;
|
||||
handleConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void;
|
||||
handleHeartbeatTick(): void;
|
||||
handleMessage(message: TMessage, signalUrl: string): void;
|
||||
}
|
||||
|
||||
export class ServerSignalingCoordinator<TMessage> {
|
||||
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
|
||||
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
|
||||
private readonly serverSignalingUrlMap = new Map<string, string>();
|
||||
private readonly peerSignalingUrlMap = new Map<string, string>();
|
||||
private readonly signalingManagers = new Map<string, SignalingManager>();
|
||||
private readonly signalingSubscriptions = new Map<string, Subscription[]>();
|
||||
private readonly peerServerMap = new Map<string, Set<string>>();
|
||||
|
||||
constructor(
|
||||
private readonly callbacks: ServerSignalingCoordinatorCallbacks<TMessage>
|
||||
) {}
|
||||
|
||||
ensureSignalingManager(signalUrl: string): SignalingManager {
|
||||
const existingManager = this.signalingManagers.get(signalUrl);
|
||||
|
||||
if (existingManager) {
|
||||
return existingManager;
|
||||
}
|
||||
|
||||
const manager = this.callbacks.createManager(
|
||||
signalUrl,
|
||||
() => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null,
|
||||
() => this.getMemberServerIdsForSignalUrl(signalUrl)
|
||||
);
|
||||
const subscriptions: Subscription[] = [
|
||||
manager.connectionStatus$.subscribe(({ connected, errorMessage }) =>
|
||||
this.callbacks.handleConnectionStatus(signalUrl, connected, errorMessage)
|
||||
),
|
||||
manager.messageReceived$.subscribe((message) =>
|
||||
this.callbacks.handleMessage(message as TMessage, signalUrl)
|
||||
),
|
||||
manager.heartbeatTick$.subscribe(() => this.callbacks.handleHeartbeatTick())
|
||||
];
|
||||
|
||||
this.signalingManagers.set(signalUrl, manager);
|
||||
this.signalingSubscriptions.set(signalUrl, subscriptions);
|
||||
return manager;
|
||||
}
|
||||
|
||||
getSignalingManager(signalUrl: string): SignalingManager | undefined {
|
||||
return this.signalingManagers.get(signalUrl);
|
||||
}
|
||||
|
||||
isSignalingConnectedTo(signalUrl: string): boolean {
|
||||
return this.signalingManagers.get(signalUrl)?.isSocketOpen() ?? false;
|
||||
}
|
||||
|
||||
isAnySignalingConnected(): boolean {
|
||||
for (const manager of this.signalingManagers.values()) {
|
||||
if (manager.isSocketOpen()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||
const connectedManagers: ConnectedSignalingManager[] = [];
|
||||
|
||||
for (const [signalUrl, manager] of this.signalingManagers.entries()) {
|
||||
if (!manager.isSocketOpen()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
connectedManagers.push({ signalUrl,
|
||||
manager });
|
||||
}
|
||||
|
||||
return connectedManagers;
|
||||
}
|
||||
|
||||
async ensureAnySignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||
if (this.isAnySignalingConnected()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const manager of this.signalingManagers.values()) {
|
||||
if (await manager.ensureConnected(timeoutMs)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastJoinedServer(signalUrl: string, joinedServer: JoinedServerInfo): void {
|
||||
this.lastJoinedServerBySignalUrl.set(signalUrl, joinedServer);
|
||||
}
|
||||
|
||||
clearLastJoinedServers(): void {
|
||||
this.lastJoinedServerBySignalUrl.clear();
|
||||
}
|
||||
|
||||
setServerSignalUrl(serverId: string, signalUrl: string): void {
|
||||
this.serverSignalingUrlMap.set(serverId, signalUrl);
|
||||
}
|
||||
|
||||
getServerSignalUrl(serverId: string): string | undefined {
|
||||
return this.serverSignalingUrlMap.get(serverId);
|
||||
}
|
||||
|
||||
deleteServerSignalUrl(serverId: string): void {
|
||||
this.serverSignalingUrlMap.delete(serverId);
|
||||
}
|
||||
|
||||
setPeerSignalUrl(peerId: string, signalUrl: string): void {
|
||||
this.peerSignalingUrlMap.set(peerId, signalUrl);
|
||||
}
|
||||
|
||||
getPeerSignalUrl(peerId: string): string | undefined {
|
||||
return this.peerSignalingUrlMap.get(peerId);
|
||||
}
|
||||
|
||||
deletePeerSignalUrl(peerId: string): void {
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
}
|
||||
|
||||
addJoinedServer(signalUrl: string, serverId: string): void {
|
||||
this.getOrCreateMemberServerSet(signalUrl).add(serverId);
|
||||
}
|
||||
|
||||
removeJoinedServer(signalUrl: string, serverId: string): void {
|
||||
this.getOrCreateMemberServerSet(signalUrl).delete(serverId);
|
||||
}
|
||||
|
||||
removeJoinedServerEverywhere(serverId: string): void {
|
||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||
memberServerIds.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet<string> {
|
||||
return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set<string>();
|
||||
}
|
||||
|
||||
getJoinedServerEntries(): { signalUrl: string; serverIds: ReadonlySet<string> }[] {
|
||||
return Array.from(this.memberServerIdsBySignalUrl.entries()).map(([signalUrl, serverIds]) => ({
|
||||
signalUrl,
|
||||
serverIds
|
||||
}));
|
||||
}
|
||||
|
||||
clearJoinedServers(): void {
|
||||
this.memberServerIdsBySignalUrl.clear();
|
||||
this.serverSignalingUrlMap.clear();
|
||||
}
|
||||
|
||||
hasJoinedServer(serverId: string): boolean {
|
||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||
if (memberServerIds.has(serverId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getJoinedServerCount(): number {
|
||||
let joinedServerCount = 0;
|
||||
|
||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||
joinedServerCount += memberServerIds.size;
|
||||
}
|
||||
|
||||
return joinedServerCount;
|
||||
}
|
||||
|
||||
getJoinedServerIds(): ReadonlySet<string> {
|
||||
const joinedServerIds = new Set<string>();
|
||||
|
||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||
memberServerIds.forEach((serverId) => joinedServerIds.add(serverId));
|
||||
}
|
||||
|
||||
return joinedServerIds;
|
||||
}
|
||||
|
||||
trackPeerInServer(peerId: string, serverId: string): void {
|
||||
if (!peerId || !serverId)
|
||||
return;
|
||||
|
||||
const trackedServers = this.peerServerMap.get(peerId) ?? new Set<string>();
|
||||
|
||||
trackedServers.add(serverId);
|
||||
this.peerServerMap.set(peerId, trackedServers);
|
||||
}
|
||||
|
||||
hasTrackedPeerServers(peerId: string): boolean {
|
||||
return this.peerServerMap.has(peerId);
|
||||
}
|
||||
|
||||
replacePeerSharedServers(peerId: string, serverIds: string[]): boolean {
|
||||
const sharedServerIds = serverIds.filter((serverId) => this.hasJoinedServer(serverId));
|
||||
|
||||
if (sharedServerIds.length === 0) {
|
||||
this.peerServerMap.delete(peerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.peerServerMap.set(peerId, new Set(sharedServerIds));
|
||||
return true;
|
||||
}
|
||||
|
||||
untrackPeerFromServer(peerId: string, serverId: string): boolean {
|
||||
const trackedServers = this.peerServerMap.get(peerId);
|
||||
|
||||
if (!trackedServers)
|
||||
return false;
|
||||
|
||||
trackedServers.delete(serverId);
|
||||
|
||||
if (trackedServers.size === 0) {
|
||||
this.peerServerMap.delete(peerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.peerServerMap.set(peerId, trackedServers);
|
||||
return true;
|
||||
}
|
||||
|
||||
deletePeerTracking(peerId: string): void {
|
||||
this.peerServerMap.delete(peerId);
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
}
|
||||
|
||||
clearPeerTracking(): void {
|
||||
this.peerServerMap.clear();
|
||||
this.peerSignalingUrlMap.clear();
|
||||
}
|
||||
|
||||
getPeersOutsideServer(serverId: string): string[] {
|
||||
const peersToClose: string[] = [];
|
||||
|
||||
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
||||
if (!peerServerIds.has(serverId)) {
|
||||
peersToClose.push(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
return peersToClose;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const subscriptions of this.signalingSubscriptions.values()) {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
for (const manager of this.signalingManagers.values()) {
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
this.signalingSubscriptions.clear();
|
||||
this.signalingManagers.clear();
|
||||
this.clearJoinedServers();
|
||||
this.clearLastJoinedServers();
|
||||
this.clearPeerTracking();
|
||||
}
|
||||
|
||||
private getOrCreateMemberServerSet(signalUrl: string): Set<string> {
|
||||
const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl);
|
||||
|
||||
if (existingSet) {
|
||||
return existingSet;
|
||||
}
|
||||
|
||||
const createdSet = new Set<string>();
|
||||
|
||||
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
|
||||
return createdSet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { SignalingMessage } from '../../../shared-kernel';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import {
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_CONNECTED,
|
||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
SIGNALING_TYPE_OFFER,
|
||||
SIGNALING_TYPE_SERVER_USERS,
|
||||
SIGNALING_TYPE_USER_JOINED,
|
||||
SIGNALING_TYPE_USER_LEFT
|
||||
} from '../realtime.constants';
|
||||
import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager';
|
||||
import { ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
|
||||
interface SignalingUserSummary {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface IncomingSignalingPayload {
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
candidate?: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
export type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
|
||||
type: string;
|
||||
payload?: IncomingSignalingPayload;
|
||||
oderId?: string;
|
||||
serverTime?: number;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: SignalingUserSummary[];
|
||||
displayName?: string;
|
||||
fromUserId?: string;
|
||||
};
|
||||
|
||||
interface IncomingSignalingMessageHandlerDependencies {
|
||||
peerManager: PeerConnectionManager;
|
||||
signalingCoordinator: ServerSignalingCoordinator<IncomingSignalingMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getEffectiveServerId(): string | null;
|
||||
setServerTime(serverTime: number): void;
|
||||
}
|
||||
|
||||
export class IncomingSignalingMessageHandler {
|
||||
constructor(
|
||||
private readonly dependencies: IncomingSignalingMessageHandlerDependencies
|
||||
) {}
|
||||
|
||||
handleMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.dependencies.logger.info('Signaling message', {
|
||||
signalUrl,
|
||||
type: message.type
|
||||
});
|
||||
|
||||
switch (message.type) {
|
||||
case SIGNALING_TYPE_CONNECTED:
|
||||
this.handleConnectedSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS:
|
||||
this.handleServerUsersSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_JOINED:
|
||||
this.handleUserJoinedSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_LEFT:
|
||||
this.handleUserLeftSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_OFFER:
|
||||
this.handleOfferSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ANSWER:
|
||||
this.handleAnswerSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||
this.handleIceCandidateSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.dependencies.logger.info('Server connected', {
|
||||
oderId: message.oderId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
if (message.serverId) {
|
||||
this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.dependencies.setServerTime(message.serverTime);
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const users = Array.isArray(message.users) ? message.users : [];
|
||||
|
||||
this.dependencies.logger.info('Server users', {
|
||||
count: users.length,
|
||||
signalUrl,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.serverId) {
|
||||
this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(user.oderId, signalUrl);
|
||||
|
||||
if (message.serverId) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId);
|
||||
}
|
||||
|
||||
const existing = this.dependencies.peerManager.activePeerConnections.get(user.oderId);
|
||||
|
||||
if (this.canReusePeerConnection(existing)) {
|
||||
this.dependencies.logger.info('Reusing active peer connection', {
|
||||
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.dependencies.logger.info('Removing failed peer before recreate', {
|
||||
connectionState: existing.connection.connectionState,
|
||||
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.removePeer(user.oderId);
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.createPeerConnection(user.oderId, true);
|
||||
void this.dependencies.peerManager.createAndSendOffer(user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.dependencies.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
if (message.serverId) {
|
||||
this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
if (message.oderId) {
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(message.oderId, signalUrl);
|
||||
}
|
||||
|
||||
if (message.oderId && message.serverId) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.dependencies.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
signalUrl,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, message.serverIds)
|
||||
: (message.serverId
|
||||
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, message.serverId)
|
||||
: false);
|
||||
|
||||
if (!hasRemainingSharedServers) {
|
||||
this.dependencies.peerManager.removePeer(message.oderId);
|
||||
this.dependencies.signalingCoordinator.deletePeerTracking(message.oderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
|
||||
const effectiveServerId = this.dependencies.getEffectiveServerId();
|
||||
|
||||
if (effectiveServerId && !this.dependencies.signalingCoordinator.hasTrackedPeerServers(fromUserId)) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId);
|
||||
}
|
||||
|
||||
this.dependencies.peerManager.handleOffer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
this.dependencies.peerManager.handleAnswer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||
}
|
||||
|
||||
private canReusePeerConnection(peer: PeerData | undefined): boolean {
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
return connectionState !== 'closed' && connectionState !== 'failed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Observable, of } from 'rxjs';
|
||||
import type { SignalingMessage } from '../../../shared-kernel';
|
||||
import { DEFAULT_DISPLAY_NAME, SIGNALING_TYPE_IDENTIFY } from '../realtime.constants';
|
||||
import { IdentifyCredentials } from '../realtime.types';
|
||||
import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
|
||||
interface SignalingTransportHandlerDependencies<TMessage> {
|
||||
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getLocalPeerId(): string;
|
||||
}
|
||||
|
||||
export class SignalingTransportHandler<TMessage> {
|
||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: SignalingTransportHandlerDependencies<TMessage>
|
||||
) {}
|
||||
|
||||
getIdentifyCredentials(): IdentifyCredentials | null {
|
||||
return this.lastIdentifyCredentials;
|
||||
}
|
||||
|
||||
getIdentifyOderId(): string {
|
||||
return this.lastIdentifyCredentials?.oderId || this.dependencies.getLocalPeerId();
|
||||
}
|
||||
|
||||
getIdentifyDisplayName(): string {
|
||||
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
}
|
||||
|
||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
||||
}
|
||||
|
||||
getCurrentSignalingUrl(activeServerId: string | null): string | null {
|
||||
if (activeServerId) {
|
||||
const activeServerSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(activeServerId);
|
||||
|
||||
if (activeServerSignalUrl) {
|
||||
return activeServerSignalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null;
|
||||
}
|
||||
|
||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||
const manager = this.dependencies.signalingCoordinator.ensureSignalingManager(serverUrl);
|
||||
|
||||
if (manager.isSocketOpen()) {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
return manager.connect(serverUrl);
|
||||
}
|
||||
|
||||
isSignalingConnectedTo(serverUrl: string): boolean {
|
||||
return this.dependencies.signalingCoordinator.isSignalingConnectedTo(serverUrl);
|
||||
}
|
||||
|
||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||
return await this.dependencies.signalingCoordinator.ensureAnySignalingConnected(timeoutMs);
|
||||
}
|
||||
|
||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||
const targetPeerId = message.to;
|
||||
|
||||
if (targetPeerId) {
|
||||
const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId);
|
||||
|
||||
if (targetSignalUrl) {
|
||||
const targetManager = this.dependencies.signalingCoordinator.ensureSignalingManager(targetSignalUrl);
|
||||
|
||||
targetManager.sendSignalingMessage(message, this.dependencies.getLocalPeerId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const connectedManagers = this.getConnectedSignalingManagers();
|
||||
|
||||
if (connectedManagers.length === 0) {
|
||||
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||
type: message.type
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { manager } of connectedManagers) {
|
||||
manager.sendSignalingMessage(message, this.dependencies.getLocalPeerId());
|
||||
}
|
||||
}
|
||||
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
|
||||
|
||||
if (targetPeerId) {
|
||||
const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId);
|
||||
|
||||
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
|
||||
|
||||
if (serverId) {
|
||||
const serverSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId);
|
||||
|
||||
if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const connectedManagers = this.getConnectedSignalingManagers();
|
||||
|
||||
if (connectedManagers.length === 0) {
|
||||
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { manager } of connectedManagers) {
|
||||
manager.sendRawMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
|
||||
const manager = this.dependencies.signalingCoordinator.getSignalingManager(signalUrl);
|
||||
|
||||
if (!manager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
manager.sendRawMessage(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
displayName: normalizedDisplayName
|
||||
};
|
||||
|
||||
const identifyMessage = {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId,
|
||||
displayName: normalizedDisplayName
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedManagers = this.getConnectedSignalingManagers();
|
||||
|
||||
if (connectedManagers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { manager } of connectedManagers) {
|
||||
manager.sendRawMessage(identifyMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion,, max-statements-per-line */
|
||||
/**
|
||||
* Manages the WebSocket connection to the signaling server,
|
||||
* including automatic reconnection and heartbeats.
|
||||
*/
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import type { SignalingMessage } from '../../../shared-kernel';
|
||||
import { recordDebugNetworkSignalingPayload } from '../logging/debug-network-metrics';
|
||||
import { IdentifyCredentials, JoinedServerInfo } from '../realtime.types';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import {
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS,
|
||||
SIGNALING_RECONNECT_MAX_DELAY_MS,
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
STATE_HEARTBEAT_INTERVAL_MS,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
} from '../realtime.constants';
|
||||
|
||||
interface ParsedSignalingPayload {
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
candidate?: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
type ParsedSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> &
|
||||
Record<string, unknown> & {
|
||||
type: string;
|
||||
payload?: ParsedSignalingPayload;
|
||||
};
|
||||
|
||||
export class SignalingManager {
|
||||
private signalingWebSocket: WebSocket | null = null;
|
||||
private lastSignalingUrl: string | null = null;
|
||||
private signalingReconnectAttempts = 0;
|
||||
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
|
||||
readonly heartbeatTick$ = new Subject<void>();
|
||||
|
||||
/** Fires whenever a raw signaling message arrives from the server. */
|
||||
readonly messageReceived$ = new Subject<ParsedSignalingMessage>();
|
||||
|
||||
/** Fires when connection status changes (true = open, false = closed/error). */
|
||||
readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private readonly getLastIdentify: () => IdentifyCredentials | null,
|
||||
private readonly getLastJoinedServer: () => JoinedServerInfo | null,
|
||||
private readonly getMemberServerIds: () => ReadonlySet<string>
|
||||
) {}
|
||||
|
||||
/** Open (or re-open) a WebSocket to the signaling server. */
|
||||
connect(serverUrl: string): Observable<boolean> {
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
return new Observable<boolean>((observer) => {
|
||||
try {
|
||||
this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
}
|
||||
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
this.signalingWebSocket = new WebSocket(serverUrl);
|
||||
|
||||
this.signalingWebSocket.onopen = () => {
|
||||
this.logger.info('[signaling] Connected to signaling server', {
|
||||
serverUrl,
|
||||
readyState: this.getSocketReadyStateLabel()
|
||||
});
|
||||
|
||||
this.clearReconnect();
|
||||
this.startHeartbeat();
|
||||
this.connectionStatus$.next({ connected: true });
|
||||
this.reIdentifyAndRejoin();
|
||||
observer.next(true);
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onmessage = (event) => {
|
||||
const rawPayload = this.stringifySocketPayload(event.data);
|
||||
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(rawPayload) as ParsedSignalingMessage;
|
||||
const payloadPreview = this.buildPayloadPreview(message);
|
||||
|
||||
recordDebugNetworkSignalingPayload(message, 'inbound');
|
||||
|
||||
this.logger.traffic('signaling', 'inbound', {
|
||||
...payloadPreview,
|
||||
bytes: payloadBytes ?? undefined,
|
||||
payloadPreview,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
type: typeof message.type === 'string' ? message.type : 'unknown',
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
this.messageReceived$.next(message);
|
||||
} catch (error) {
|
||||
this.logger.error('[signaling] Failed to parse signaling message', error, {
|
||||
bytes: payloadBytes ?? undefined,
|
||||
rawPreview: this.getPayloadPreview(rawPayload),
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: serverUrl
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onerror = (error) => {
|
||||
this.logger.error('[signaling] Signaling socket error', error, {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
this.connectionStatus$.next({ connected: false,
|
||||
errorMessage: 'Connection to signaling server failed' });
|
||||
|
||||
observer.error(error);
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onclose = (event) => {
|
||||
this.logger.warn('[signaling] Disconnected from signaling server', {
|
||||
attempts: this.signalingReconnectAttempts,
|
||||
code: event.code,
|
||||
reason: event.reason || null,
|
||||
url: serverUrl,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
|
||||
this.stopHeartbeat();
|
||||
this.connectionStatus$.next({ connected: false,
|
||||
errorMessage: 'Disconnected from signaling server' });
|
||||
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('[signaling] Failed to initialize signaling socket', error, {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
observer.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure signaling is connected; try reconnecting if not. */
|
||||
async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> {
|
||||
if (this.isSocketOpen())
|
||||
return true;
|
||||
|
||||
if (!this.lastSignalingUrl)
|
||||
return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) { settled = true; resolve(false); }
|
||||
}, timeoutMs);
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
|
||||
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a signaling message (with `from` / `timestamp` populated). */
|
||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
|
||||
if (!this.isSocketOpen()) {
|
||||
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
type: message.type,
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: SignalingMessage = { ...message,
|
||||
from: localPeerId,
|
||||
timestamp: Date.now() };
|
||||
|
||||
this.sendSerializedPayload(fullMessage, {
|
||||
targetPeerId: message.to,
|
||||
type: message.type,
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a raw JSON payload (for identify, join_server, etc.). */
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
if (!this.isSocketOpen()) {
|
||||
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendSerializedPayload(message, {
|
||||
targetPeerId: typeof message['targetUserId'] === 'string' ? message['targetUserId'] : undefined,
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
}
|
||||
|
||||
/** Gracefully close the WebSocket. */
|
||||
close(): void {
|
||||
this.stopHeartbeat();
|
||||
this.clearReconnect();
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
this.signalingWebSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the underlying WebSocket is currently open. */
|
||||
isSocketOpen(): boolean {
|
||||
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** The URL last used to connect (needed for reconnection). */
|
||||
getLastUrl(): string | null {
|
||||
return this.lastSignalingUrl;
|
||||
}
|
||||
|
||||
/** Re-identify and rejoin servers after a reconnect. */
|
||||
private reIdentifyAndRejoin(): void {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName });
|
||||
}
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
|
||||
if (memberIds.size > 0) {
|
||||
memberIds.forEach((serverId) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId });
|
||||
});
|
||||
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId: lastJoined.serverId });
|
||||
}
|
||||
} else {
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: lastJoined.serverId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnect attempt using exponential backoff.
|
||||
*
|
||||
* The delay doubles with each attempt up to {@link SIGNALING_RECONNECT_MAX_DELAY_MS}.
|
||||
* No-ops if a timer is already pending or no URL is stored.
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl)
|
||||
return;
|
||||
|
||||
const delay = Math.min(
|
||||
SIGNALING_RECONNECT_MAX_DELAY_MS,
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts)
|
||||
);
|
||||
|
||||
this.signalingReconnectTimer = setTimeout(() => {
|
||||
this.signalingReconnectTimer = null;
|
||||
this.signalingReconnectAttempts++;
|
||||
this.logger.info('[signaling] Attempting reconnect', {
|
||||
attempt: this.signalingReconnectAttempts,
|
||||
delay,
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { this.signalingReconnectAttempts = 0; },
|
||||
error: () => { this.scheduleReconnect(); }
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Cancel any pending reconnect timer and reset the attempt counter. */
|
||||
private clearReconnect(): void {
|
||||
if (this.signalingReconnectTimer) {
|
||||
clearTimeout(this.signalingReconnectTimer);
|
||||
this.signalingReconnectTimer = null;
|
||||
}
|
||||
|
||||
this.signalingReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/** Start the heartbeat interval that drives periodic state broadcasts. */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the heartbeat interval. */
|
||||
private stopHeartbeat(): void {
|
||||
if (this.stateHeartbeatTimer) {
|
||||
clearInterval(this.stateHeartbeatTimer);
|
||||
this.stateHeartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.close();
|
||||
this.heartbeatTick$.complete();
|
||||
this.messageReceived$.complete();
|
||||
this.connectionStatus$.complete();
|
||||
}
|
||||
|
||||
private sendSerializedPayload(
|
||||
message: SignalingMessage | Record<string, unknown>,
|
||||
details: { targetPeerId?: string; type?: string; url?: string | null }
|
||||
): void {
|
||||
let rawPayload = '';
|
||||
|
||||
const payloadPreview = this.buildPayloadPreview(message);
|
||||
|
||||
recordDebugNetworkSignalingPayload(message, 'outbound');
|
||||
|
||||
try {
|
||||
rawPayload = JSON.stringify(message);
|
||||
} catch (error) {
|
||||
this.logger.error('[signaling] Failed to serialize signaling payload', error, {
|
||||
payloadPreview,
|
||||
type: details.type,
|
||||
url: details.url
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
this.signalingWebSocket!.send(rawPayload);
|
||||
this.logger.traffic('signaling', 'outbound', {
|
||||
...payloadPreview,
|
||||
bytes: this.measurePayloadBytes(rawPayload),
|
||||
payloadPreview,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
targetPeerId: details.targetPeerId,
|
||||
type: details.type,
|
||||
url: details.url
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('[signaling] Failed to send signaling payload', error, {
|
||||
bytes: this.measurePayloadBytes(rawPayload),
|
||||
payloadPreview,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
targetPeerId: details.targetPeerId,
|
||||
type: details.type,
|
||||
url: details.url
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getSocketReadyStateLabel(): string {
|
||||
const readyState = this.signalingWebSocket?.readyState;
|
||||
|
||||
switch (readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return 'connecting';
|
||||
case WebSocket.OPEN:
|
||||
return 'open';
|
||||
case WebSocket.CLOSING:
|
||||
return 'closing';
|
||||
case WebSocket.CLOSED:
|
||||
return 'closed';
|
||||
default:
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
private stringifySocketPayload(payload: unknown): string {
|
||||
if (typeof payload === 'string')
|
||||
return payload;
|
||||
|
||||
if (payload instanceof ArrayBuffer)
|
||||
return new TextDecoder().decode(payload);
|
||||
|
||||
return String(payload ?? '');
|
||||
}
|
||||
|
||||
private measurePayloadBytes(payload: string): number {
|
||||
return new TextEncoder().encode(payload).length;
|
||||
}
|
||||
|
||||
private getPayloadPreview(payload: string): string {
|
||||
return payload.replace(/\s+/g, ' ').slice(0, 240);
|
||||
}
|
||||
|
||||
private buildPayloadPreview(payload: SignalingMessage | Record<string, unknown>): Record<string, unknown> {
|
||||
const record = payload as Record<string, unknown>;
|
||||
const voiceState = this.summarizeVoiceState(record['voiceState']);
|
||||
const users = this.summarizeUsers(record['users']);
|
||||
|
||||
return {
|
||||
displayName: typeof record['displayName'] === 'string' ? record['displayName'] : undefined,
|
||||
fromUserId: typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined,
|
||||
isScreenSharing: typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined,
|
||||
keys: Object.keys(record).slice(0, 10),
|
||||
oderId: typeof record['oderId'] === 'string' ? record['oderId'] : undefined,
|
||||
roomId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
|
||||
serverId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
|
||||
targetPeerId: typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined,
|
||||
type: typeof record['type'] === 'string' ? record['type'] : 'unknown',
|
||||
userCount: Array.isArray(record['users']) ? record['users'].length : undefined,
|
||||
users,
|
||||
voiceState
|
||||
};
|
||||
}
|
||||
|
||||
private summarizeVoiceState(value: unknown): Record<string, unknown> | undefined {
|
||||
const voiceState = this.asRecord(value);
|
||||
|
||||
if (!voiceState)
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
isConnected: voiceState['isConnected'] === true,
|
||||
isMuted: voiceState['isMuted'] === true,
|
||||
isDeafened: voiceState['isDeafened'] === true,
|
||||
isSpeaking: voiceState['isSpeaking'] === true,
|
||||
roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined,
|
||||
serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined,
|
||||
volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private summarizeUsers(value: unknown): Record<string, unknown>[] | undefined {
|
||||
if (!Array.isArray(value))
|
||||
return undefined;
|
||||
|
||||
const users: Record<string, unknown>[] = [];
|
||||
|
||||
for (const userValue of value.slice(0, 20)) {
|
||||
const user = this.asRecord(userValue);
|
||||
|
||||
if (!user)
|
||||
continue;
|
||||
|
||||
users.push({
|
||||
displayName: typeof user['displayName'] === 'string' ? user['displayName'] : undefined,
|
||||
oderId: typeof user['oderId'] === 'string' ? user['oderId'] : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return null;
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user