346 lines
9.6 KiB
TypeScript
346 lines
9.6 KiB
TypeScript
import {
|
|
SIGNALING_TYPE_ANSWER,
|
|
SIGNALING_TYPE_OFFER,
|
|
TRACK_KIND_AUDIO,
|
|
TRACK_KIND_VIDEO,
|
|
TRANSCEIVER_SEND_RECV
|
|
} from '../../realtime.constants';
|
|
import {
|
|
NegotiationHandlers,
|
|
PeerConnectionManagerContext,
|
|
PeerConnectionManagerState
|
|
} from '../shared';
|
|
import type { PeerData } from '../../realtime.types';
|
|
|
|
/**
|
|
* Queue a negotiation task so SDP operations for a single peer never overlap.
|
|
*/
|
|
export function enqueueNegotiation(
|
|
state: PeerConnectionManagerState,
|
|
peerId: string,
|
|
task: () => Promise<void>
|
|
): void {
|
|
const previousTask = state.peerNegotiationQueue.get(peerId) ?? Promise.resolve();
|
|
const nextTask = previousTask.then(task, task);
|
|
|
|
state.peerNegotiationQueue.set(peerId, nextTask);
|
|
}
|
|
|
|
export async function doCreateAndSendOffer(
|
|
context: PeerConnectionManagerContext,
|
|
remotePeerId: string
|
|
): Promise<void> {
|
|
const { callbacks, logger, state } = context;
|
|
const peerData = state.activePeerConnections.get(remotePeerId);
|
|
|
|
if (!peerData)
|
|
return;
|
|
|
|
try {
|
|
const offer = await peerData.connection.createOffer();
|
|
|
|
await peerData.connection.setLocalDescription(offer);
|
|
logger.info('Sending offer', {
|
|
remotePeerId,
|
|
type: offer.type,
|
|
sdpLength: offer.sdp?.length
|
|
});
|
|
|
|
callbacks.sendRawMessage({
|
|
type: SIGNALING_TYPE_OFFER,
|
|
targetUserId: remotePeerId,
|
|
payload: { sdp: offer }
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to create offer', error, {
|
|
localDescriptionType: peerData.connection.localDescription?.type ?? null,
|
|
remotePeerId,
|
|
signalingState: peerData.connection.signalingState
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace a peer whose underlying connection is `failed` or `closed`.
|
|
* Returns the existing peer if still usable, or `undefined` after cleanup.
|
|
*/
|
|
function replaceUnusablePeer(
|
|
context: PeerConnectionManagerContext,
|
|
peerId: string,
|
|
reason: string
|
|
): void {
|
|
const { logger, state } = context;
|
|
const peerData = state.activePeerConnections.get(peerId);
|
|
|
|
if (!peerData)
|
|
return;
|
|
|
|
const cs = peerData.connection.connectionState;
|
|
|
|
if (cs !== 'failed' && cs !== 'closed')
|
|
return;
|
|
|
|
logger.info('Replacing unusable peer', {
|
|
connectionState: cs,
|
|
peerId,
|
|
reason,
|
|
signalingState: peerData.connection.signalingState
|
|
});
|
|
|
|
try {
|
|
peerData.connection.close();
|
|
} catch { /* already closing */ }
|
|
|
|
state.activePeerConnections.delete(peerId);
|
|
state.peerNegotiationQueue.delete(peerId);
|
|
}
|
|
|
|
function getOrCreatePeerForOffer(
|
|
state: PeerConnectionManagerState,
|
|
fromUserId: string,
|
|
handlers: NegotiationHandlers
|
|
): PeerData {
|
|
return state.activePeerConnections.get(fromUserId) ?? handlers.createPeerConnection(fromUserId, false);
|
|
}
|
|
|
|
async function resolveOfferCollision(
|
|
peerData: PeerData,
|
|
callbacks: PeerConnectionManagerContext['callbacks'],
|
|
logger: PeerConnectionManagerContext['logger'],
|
|
fromUserId: string
|
|
): Promise<boolean> {
|
|
const signalingState = peerData.connection.signalingState;
|
|
const hasCollision = signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
|
|
|
if (!hasCollision)
|
|
return true;
|
|
|
|
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
|
const isPolite = !localOderId || localOderId > fromUserId;
|
|
|
|
if (!isPolite) {
|
|
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
|
return false;
|
|
}
|
|
|
|
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
|
|
|
|
await peerData.connection.setLocalDescription({
|
|
type: 'rollback'
|
|
} as RTCSessionDescriptionInit);
|
|
|
|
return true;
|
|
}
|
|
|
|
function syncPeerSendersFromTransceivers(peerData: PeerData): void {
|
|
const transceivers = peerData.connection.getTransceivers();
|
|
|
|
for (const transceiver of transceivers) {
|
|
const receiverKind = transceiver.receiver.track?.kind;
|
|
|
|
if (receiverKind === TRACK_KIND_AUDIO) {
|
|
if (!peerData.audioSender) {
|
|
peerData.audioSender = transceiver.sender;
|
|
}
|
|
|
|
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
|
continue;
|
|
}
|
|
|
|
if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
|
peerData.videoSender = transceiver.sender;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function attachAnswererLocalTracks(
|
|
peerData: PeerData,
|
|
localStream: MediaStream | null,
|
|
logger: PeerConnectionManagerContext['logger'],
|
|
fromUserId: string
|
|
): Promise<void> {
|
|
if (!localStream)
|
|
return;
|
|
|
|
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
|
|
|
for (const track of localStream.getTracks()) {
|
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
|
await peerData.audioSender.replaceTrack(track);
|
|
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
|
continue;
|
|
}
|
|
|
|
if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
|
await peerData.videoSender.replaceTrack(track);
|
|
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function applyPendingIceCandidates(peerData: PeerData): Promise<void> {
|
|
for (const candidate of peerData.pendingIceCandidates) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
}
|
|
|
|
peerData.pendingIceCandidates = [];
|
|
}
|
|
|
|
export async function doHandleOffer(
|
|
context: PeerConnectionManagerContext,
|
|
fromUserId: string,
|
|
sdp: RTCSessionDescriptionInit,
|
|
handlers: NegotiationHandlers
|
|
): Promise<void> {
|
|
const { callbacks, logger, state } = context;
|
|
|
|
logger.info('Handling offer', { fromUserId });
|
|
|
|
replaceUnusablePeer(context, fromUserId, 'incoming offer');
|
|
|
|
const peerData = getOrCreatePeerForOffer(state, fromUserId, handlers);
|
|
|
|
try {
|
|
const shouldApplyOffer = await resolveOfferCollision(peerData, callbacks, logger, fromUserId);
|
|
|
|
if (!shouldApplyOffer)
|
|
return;
|
|
|
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
syncPeerSendersFromTransceivers(peerData);
|
|
await attachAnswererLocalTracks(peerData, callbacks.getLocalMediaStream(), logger, fromUserId);
|
|
await applyPendingIceCandidates(peerData);
|
|
|
|
const answer = await peerData.connection.createAnswer();
|
|
|
|
await peerData.connection.setLocalDescription(answer);
|
|
|
|
logger.info('Sending answer', {
|
|
to: fromUserId,
|
|
type: answer.type,
|
|
sdpLength: answer.sdp?.length
|
|
});
|
|
|
|
callbacks.sendRawMessage({
|
|
type: SIGNALING_TYPE_ANSWER,
|
|
targetUserId: fromUserId,
|
|
payload: { sdp: answer }
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to handle offer', error, {
|
|
fromUserId,
|
|
pendingIceCandidates: peerData.pendingIceCandidates.length,
|
|
sdpLength: sdp.sdp?.length,
|
|
signalingState: peerData.connection.signalingState
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function doHandleAnswer(
|
|
context: PeerConnectionManagerContext,
|
|
fromUserId: string,
|
|
sdp: RTCSessionDescriptionInit
|
|
): Promise<void> {
|
|
const { logger, state } = context;
|
|
|
|
logger.info('Handling answer', { fromUserId });
|
|
|
|
const peerData = state.activePeerConnections.get(fromUserId);
|
|
|
|
if (!peerData) {
|
|
logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (peerData.connection.signalingState === 'have-local-offer') {
|
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
|
|
for (const candidate of peerData.pendingIceCandidates) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
}
|
|
|
|
peerData.pendingIceCandidates = [];
|
|
} else {
|
|
logger.warn('Ignoring answer - wrong signaling state', {
|
|
state: peerData.connection.signalingState
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to handle answer', error, {
|
|
fromUserId,
|
|
pendingIceCandidates: peerData.pendingIceCandidates.length,
|
|
sdpLength: sdp.sdp?.length,
|
|
signalingState: peerData.connection.signalingState
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function doHandleIceCandidate(
|
|
context: PeerConnectionManagerContext,
|
|
fromUserId: string,
|
|
candidate: RTCIceCandidateInit,
|
|
handlers: NegotiationHandlers
|
|
): Promise<void> {
|
|
const { logger, state } = context;
|
|
|
|
replaceUnusablePeer(context, fromUserId, 'early ICE');
|
|
|
|
let peerData = state.activePeerConnections.get(fromUserId);
|
|
|
|
if (!peerData) {
|
|
logger.info('Creating peer for early ICE', { fromUserId });
|
|
peerData = handlers.createPeerConnection(fromUserId, false);
|
|
}
|
|
|
|
try {
|
|
if (peerData.connection.remoteDescription) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
} else {
|
|
logger.info('Queuing ICE candidate', { fromUserId });
|
|
peerData.pendingIceCandidates.push(candidate);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to add ICE candidate', error, {
|
|
candidateMid: candidate.sdpMid ?? null,
|
|
candidateMLineIndex: candidate.sdpMLineIndex ?? null,
|
|
fromUserId,
|
|
hasRemoteDescription: !!peerData.connection.remoteDescription,
|
|
pendingIceCandidates: peerData.pendingIceCandidates.length
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function doRenegotiate(
|
|
context: PeerConnectionManagerContext,
|
|
peerId: string
|
|
): Promise<void> {
|
|
const { callbacks, logger, state } = context;
|
|
const peerData = state.activePeerConnections.get(peerId);
|
|
|
|
if (!peerData)
|
|
return;
|
|
|
|
try {
|
|
const offer = await peerData.connection.createOffer();
|
|
|
|
await peerData.connection.setLocalDescription(offer);
|
|
logger.info('Renegotiate offer', {
|
|
peerId,
|
|
type: offer.type,
|
|
sdpLength: offer.sdp?.length
|
|
});
|
|
|
|
callbacks.sendRawMessage({
|
|
type: SIGNALING_TYPE_OFFER,
|
|
targetUserId: peerId,
|
|
payload: { sdp: offer }
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to renegotiate', error, {
|
|
peerId,
|
|
signalingState: peerData.connection.signalingState
|
|
});
|
|
}
|
|
}
|