Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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';
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>;
}
}