230 lines
6.9 KiB
TypeScript
230 lines
6.9 KiB
TypeScript
import {
|
|
CONNECTION_STATE_CLOSED,
|
|
CONNECTION_STATE_CONNECTED,
|
|
CONNECTION_STATE_DISCONNECTED,
|
|
CONNECTION_STATE_FAILED,
|
|
DATA_CHANNEL_LABEL,
|
|
SIGNALING_TYPE_ICE_CANDIDATE,
|
|
TRACK_KIND_AUDIO,
|
|
TRACK_KIND_VIDEO,
|
|
TRANSCEIVER_RECV_ONLY,
|
|
TRANSCEIVER_SEND_RECV
|
|
} from '../../realtime.constants';
|
|
import { recordDebugNetworkConnectionState } from '../../logging/debug-network-metrics';
|
|
import { PeerData } from '../../realtime.types';
|
|
import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared';
|
|
|
|
/**
|
|
* Create and configure a new RTCPeerConnection for a remote peer.
|
|
*/
|
|
export function createPeerConnection(
|
|
context: PeerConnectionManagerContext,
|
|
remotePeerId: string,
|
|
isInitiator: boolean,
|
|
handlers: ConnectionLifecycleHandlers
|
|
): PeerData {
|
|
const { callbacks, logger, state } = context;
|
|
|
|
logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
|
|
|
const connection = new RTCPeerConnection({ iceServers: callbacks.getIceServers() });
|
|
|
|
let dataChannel: RTCDataChannel | null = null;
|
|
let peerData: PeerData | null = null;
|
|
|
|
const adoptDataChannel = (channel: RTCDataChannel): void => {
|
|
const primaryChannel = dataChannel;
|
|
const shouldAdoptAsPrimary = !primaryChannel || primaryChannel.readyState === 'closed';
|
|
|
|
if (shouldAdoptAsPrimary) {
|
|
dataChannel = channel;
|
|
|
|
if (peerData) {
|
|
peerData.dataChannel = channel;
|
|
}
|
|
|
|
const existing = state.activePeerConnections.get(remotePeerId);
|
|
|
|
if (existing) {
|
|
existing.dataChannel = channel;
|
|
}
|
|
} else if (primaryChannel !== channel) {
|
|
logger.info('Received secondary data channel while primary channel is still active', {
|
|
channelLabel: channel.label,
|
|
primaryChannelLabel: primaryChannel.label,
|
|
primaryReadyState: primaryChannel.readyState,
|
|
remotePeerId
|
|
});
|
|
}
|
|
|
|
handlers.setupDataChannel(channel, remotePeerId);
|
|
};
|
|
|
|
connection.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
if (!callbacks.isSignalingConnected()) {
|
|
return;
|
|
}
|
|
|
|
logger.info('ICE candidate gathered', {
|
|
remotePeerId,
|
|
candidateType: (event.candidate as RTCIceCandidate & { type?: string }).type
|
|
});
|
|
|
|
callbacks.sendRawMessage({
|
|
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
|
targetUserId: remotePeerId,
|
|
payload: { candidate: event.candidate }
|
|
});
|
|
}
|
|
};
|
|
|
|
connection.onconnectionstatechange = () => {
|
|
logger.info('connectionstatechange', {
|
|
remotePeerId,
|
|
state: connection.connectionState
|
|
});
|
|
|
|
// Ignore events from a connection that was already replaced in the Map
|
|
// (e.g. handleOffer recreated the peer while this handler was still queued).
|
|
const currentPeer = state.activePeerConnections.get(remotePeerId);
|
|
|
|
if (currentPeer && currentPeer.connection !== connection) {
|
|
logger.info('Ignoring stale connectionstatechange', {
|
|
remotePeerId,
|
|
state: connection.connectionState
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
recordDebugNetworkConnectionState(remotePeerId, connection.connectionState);
|
|
|
|
switch (connection.connectionState) {
|
|
case CONNECTION_STATE_CONNECTED:
|
|
handlers.clearPeerDisconnectGraceTimer(remotePeerId);
|
|
handlers.addToConnectedPeers(remotePeerId);
|
|
state.peerConnected$.next(remotePeerId);
|
|
handlers.clearPeerReconnectTimer(remotePeerId);
|
|
state.disconnectedPeerTracker.delete(remotePeerId);
|
|
handlers.requestVoiceStateFromPeer(remotePeerId);
|
|
break;
|
|
|
|
case CONNECTION_STATE_DISCONNECTED:
|
|
handlers.schedulePeerDisconnectRecovery(remotePeerId);
|
|
break;
|
|
|
|
case CONNECTION_STATE_FAILED:
|
|
handlers.trackDisconnectedPeer(remotePeerId);
|
|
handlers.removePeer(remotePeerId, { preserveReconnectState: true });
|
|
handlers.schedulePeerReconnect(remotePeerId);
|
|
break;
|
|
|
|
case CONNECTION_STATE_CLOSED:
|
|
handlers.removePeer(remotePeerId);
|
|
break;
|
|
}
|
|
};
|
|
|
|
connection.oniceconnectionstatechange = () => {
|
|
logger.info('iceconnectionstatechange', {
|
|
remotePeerId,
|
|
state: connection.iceConnectionState
|
|
});
|
|
};
|
|
|
|
connection.onsignalingstatechange = () => {
|
|
logger.info('signalingstatechange', {
|
|
remotePeerId,
|
|
state: connection.signalingState
|
|
});
|
|
};
|
|
|
|
connection.onnegotiationneeded = () => {
|
|
logger.info('negotiationneeded', { remotePeerId });
|
|
};
|
|
|
|
connection.ontrack = (event) => {
|
|
handlers.handleRemoteTrack(event, remotePeerId);
|
|
};
|
|
|
|
connection.ondatachannel = (event) => {
|
|
logger.info('Received data channel', { remotePeerId });
|
|
adoptDataChannel(event.channel);
|
|
};
|
|
|
|
if (isInitiator) {
|
|
dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
|
handlers.setupDataChannel(dataChannel, remotePeerId);
|
|
}
|
|
|
|
peerData = {
|
|
connection,
|
|
dataChannel,
|
|
createdAt: Date.now(),
|
|
isInitiator,
|
|
pendingIceCandidates: [],
|
|
audioSender: undefined,
|
|
videoSender: undefined,
|
|
remoteVoiceStreamIds: new Set<string>(),
|
|
remoteCameraStreamIds: new Set<string>(),
|
|
remoteScreenShareStreamIds: new Set<string>()
|
|
};
|
|
|
|
if (isInitiator) {
|
|
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, {
|
|
direction: TRANSCEIVER_SEND_RECV
|
|
});
|
|
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, {
|
|
direction: TRANSCEIVER_RECV_ONLY
|
|
});
|
|
|
|
peerData.audioSender = audioTransceiver.sender;
|
|
peerData.videoSender = videoTransceiver.sender;
|
|
}
|
|
|
|
state.activePeerConnections.set(remotePeerId, peerData);
|
|
|
|
const localStream = callbacks.getLocalMediaStream();
|
|
|
|
if (localStream && isInitiator) {
|
|
logger.logStream(`localStream->${remotePeerId}`, localStream);
|
|
|
|
localStream.getTracks().forEach((track) => {
|
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
|
if (typeof peerData.audioSender.setStreams === 'function') {
|
|
peerData.audioSender.setStreams(localStream);
|
|
}
|
|
|
|
peerData.audioSender
|
|
.replaceTrack(track)
|
|
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
|
.catch((error) =>
|
|
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
|
);
|
|
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
|
if (typeof peerData.videoSender.setStreams === 'function') {
|
|
peerData.videoSender.setStreams(localStream);
|
|
}
|
|
|
|
peerData.videoSender
|
|
.replaceTrack(track)
|
|
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
|
.catch((error) =>
|
|
logger.error('video replaceTrack failed at createPeerConnection', error)
|
|
);
|
|
} else {
|
|
const sender = connection.addTrack(track, localStream);
|
|
|
|
if (track.kind === TRACK_KIND_AUDIO)
|
|
peerData.audioSender = sender;
|
|
|
|
if (track.kind === TRACK_KIND_VIDEO)
|
|
peerData.videoSender = sender;
|
|
}
|
|
});
|
|
}
|
|
|
|
return peerData;
|
|
}
|