mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 10:53:24 +00:00
feat: Replace RondevuConnection with RondevuPeer state machine
- Created type-safe EventEmitter with generics - Implemented state pattern for peer connection lifecycle - Added comprehensive timeout handling for all connection phases - Removed client-provided offer IDs (server generates hash-based IDs) - Replaced RondevuConnection with RondevuPeer throughout - Added states: idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed - Configurable timeouts: ICE gathering, waiting for answer, creating answer, ICE connection - Better error handling with 'failed' event and error details 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,388 +0,0 @@
|
||||
import { RondevuOffers, RTCIceCandidateInit } from './offers.js';
|
||||
|
||||
/**
|
||||
* Events emitted by RondevuConnection
|
||||
*/
|
||||
export interface RondevuConnectionEvents {
|
||||
'connecting': () => void;
|
||||
'connected': () => void;
|
||||
'disconnected': () => void;
|
||||
'error': (error: Error) => void;
|
||||
'datachannel': (channel: RTCDataChannel) => void;
|
||||
'track': (event: RTCTrackEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a WebRTC connection
|
||||
*/
|
||||
export interface ConnectionOptions {
|
||||
/**
|
||||
* RTCConfiguration for the peer connection
|
||||
* @default { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
|
||||
*/
|
||||
rtcConfig?: RTCConfiguration;
|
||||
|
||||
/**
|
||||
* Topics to advertise this connection under
|
||||
*/
|
||||
topics: string[];
|
||||
|
||||
/**
|
||||
* How long the offer should live (milliseconds)
|
||||
* @default 300000 (5 minutes)
|
||||
*/
|
||||
ttl?: number;
|
||||
|
||||
/**
|
||||
* Whether to create a data channel automatically (for offerer)
|
||||
* @default true
|
||||
*/
|
||||
createDataChannel?: boolean;
|
||||
|
||||
/**
|
||||
* Label for the automatically created data channel
|
||||
* @default 'data'
|
||||
*/
|
||||
dataChannelLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level WebRTC connection manager for Rondevu
|
||||
* Handles offer/answer exchange, ICE candidates, and connection lifecycle
|
||||
*/
|
||||
export class RondevuConnection {
|
||||
private pc: RTCPeerConnection;
|
||||
private offersApi: RondevuOffers;
|
||||
private offerId?: string;
|
||||
private role?: 'offerer' | 'answerer';
|
||||
private icePollingInterval?: ReturnType<typeof setInterval>;
|
||||
private answerPollingInterval?: ReturnType<typeof setInterval>;
|
||||
private lastIceTimestamp: number = 0; // Start at 0 to get all candidates on first poll
|
||||
private eventListeners: Map<keyof RondevuConnectionEvents, Set<Function>> = new Map();
|
||||
private dataChannel?: RTCDataChannel;
|
||||
private pendingIceCandidates: RTCIceCandidateInit[] = [];
|
||||
|
||||
/**
|
||||
* Current connection state
|
||||
*/
|
||||
get connectionState(): RTCPeerConnectionState {
|
||||
return this.pc.connectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer ID for this connection
|
||||
*/
|
||||
get id(): string | undefined {
|
||||
return this.offerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary data channel (if created)
|
||||
*/
|
||||
get channel(): RTCDataChannel | undefined {
|
||||
return this.dataChannel;
|
||||
}
|
||||
|
||||
constructor(
|
||||
offersApi: RondevuOffers,
|
||||
private rtcConfig: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
}
|
||||
) {
|
||||
this.offersApi = offersApi;
|
||||
this.pc = new RTCPeerConnection(rtcConfig);
|
||||
this.setupPeerConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up peer connection event handlers
|
||||
*/
|
||||
private setupPeerConnection(): void {
|
||||
this.pc.onicecandidate = async (event) => {
|
||||
if (event.candidate) {
|
||||
// Convert RTCIceCandidate to RTCIceCandidateInit (plain object)
|
||||
const candidateData: RTCIceCandidateInit = {
|
||||
candidate: event.candidate.candidate,
|
||||
sdpMid: event.candidate.sdpMid,
|
||||
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||||
usernameFragment: event.candidate.usernameFragment,
|
||||
};
|
||||
|
||||
if (this.offerId) {
|
||||
// offerId is set, send immediately (trickle ICE)
|
||||
try {
|
||||
await this.offersApi.addIceCandidates(this.offerId, [candidateData]);
|
||||
} catch (err) {
|
||||
console.error('Error sending ICE candidate:', err);
|
||||
}
|
||||
} else {
|
||||
// offerId not set yet, buffer the candidate
|
||||
this.pendingIceCandidates.push(candidateData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onconnectionstatechange = () => {
|
||||
switch (this.pc.connectionState) {
|
||||
case 'connecting':
|
||||
this.emit('connecting');
|
||||
break;
|
||||
case 'connected':
|
||||
this.emit('connected');
|
||||
// Stop polling once connected - we have all the ICE candidates we need
|
||||
this.stopPolling();
|
||||
break;
|
||||
case 'disconnected':
|
||||
case 'failed':
|
||||
case 'closed':
|
||||
this.emit('disconnected');
|
||||
this.stopPolling();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.ondatachannel = (event) => {
|
||||
this.dataChannel = event.channel;
|
||||
this.emit('datachannel', event.channel);
|
||||
};
|
||||
|
||||
this.pc.ontrack = (event) => {
|
||||
this.emit('track', event);
|
||||
};
|
||||
|
||||
this.pc.onicecandidateerror = (event) => {
|
||||
console.error('ICE candidate error:', event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered ICE candidates (trickle ICE support)
|
||||
*/
|
||||
private async flushPendingIceCandidates(): Promise<void> {
|
||||
if (this.pendingIceCandidates.length > 0 && this.offerId) {
|
||||
try {
|
||||
await this.offersApi.addIceCandidates(this.offerId, this.pendingIceCandidates);
|
||||
this.pendingIceCandidates = [];
|
||||
} catch (err) {
|
||||
console.error('Error flushing pending ICE candidates:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an offer and advertise on topics
|
||||
*/
|
||||
async createOffer(options: ConnectionOptions): Promise<string> {
|
||||
this.role = 'offerer';
|
||||
|
||||
// Create data channel if requested
|
||||
if (options.createDataChannel !== false) {
|
||||
this.dataChannel = this.pc.createDataChannel(
|
||||
options.dataChannelLabel || 'data'
|
||||
);
|
||||
this.emit('datachannel', this.dataChannel);
|
||||
}
|
||||
|
||||
// Create WebRTC offer
|
||||
const offer = await this.pc.createOffer();
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
// Create offer on Rondevu server
|
||||
const offers = await this.offersApi.create([{
|
||||
sdp: offer.sdp!,
|
||||
topics: options.topics,
|
||||
ttl: options.ttl || 300000
|
||||
}]);
|
||||
|
||||
this.offerId = offers[0].id;
|
||||
|
||||
// Flush any ICE candidates that were generated during offer creation
|
||||
await this.flushPendingIceCandidates();
|
||||
|
||||
// Start polling for answers
|
||||
this.startAnswerPolling();
|
||||
|
||||
return this.offerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an existing offer
|
||||
*/
|
||||
async answer(offerId: string, offerSdp: string): Promise<void> {
|
||||
this.role = 'answerer';
|
||||
|
||||
// Set remote description
|
||||
await this.pc.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp: offerSdp
|
||||
});
|
||||
|
||||
// Create answer
|
||||
const answer = await this.pc.createAnswer();
|
||||
await this.pc.setLocalDescription(answer);
|
||||
|
||||
// Send answer to server FIRST
|
||||
// This registers us as the answerer before ICE candidates arrive
|
||||
await this.offersApi.answer(offerId, answer.sdp!);
|
||||
|
||||
// Now set offerId to enable ICE candidate sending
|
||||
// This prevents a race condition where ICE candidates arrive before answer is registered
|
||||
this.offerId = offerId;
|
||||
|
||||
// Flush any ICE candidates that were generated during answer creation
|
||||
await this.flushPendingIceCandidates();
|
||||
|
||||
// Start polling for ICE candidates
|
||||
this.startIcePolling();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for answers (offerer only)
|
||||
*/
|
||||
private startAnswerPolling(): void {
|
||||
if (this.role !== 'offerer' || !this.offerId) return;
|
||||
|
||||
this.answerPollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const answers = await this.offersApi.getAnswers();
|
||||
const myAnswer = answers.find(a => a.offerId === this.offerId);
|
||||
|
||||
if (myAnswer) {
|
||||
// Set remote description
|
||||
await this.pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: myAnswer.sdp
|
||||
});
|
||||
|
||||
// Stop answer polling, start ICE polling
|
||||
this.stopAnswerPolling();
|
||||
this.startIcePolling();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling for answers:', err);
|
||||
// Stop polling if offer expired/not found
|
||||
if (err instanceof Error && err.message.includes('not found')) {
|
||||
this.stopPolling();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for ICE candidates
|
||||
*/
|
||||
private startIcePolling(): void {
|
||||
if (!this.offerId) return;
|
||||
|
||||
this.icePollingInterval = setInterval(async () => {
|
||||
if (!this.offerId) return;
|
||||
|
||||
try {
|
||||
const candidates = await this.offersApi.getIceCandidates(
|
||||
this.offerId,
|
||||
this.lastIceTimestamp
|
||||
);
|
||||
|
||||
for (const cand of candidates) {
|
||||
// Use the candidate object directly - it's already RTCIceCandidateInit
|
||||
await this.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
|
||||
this.lastIceTimestamp = cand.createdAt;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling for ICE candidates:', err);
|
||||
// Stop polling if offer expired/not found
|
||||
if (err instanceof Error && err.message.includes('not found')) {
|
||||
this.stopPolling();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop answer polling
|
||||
*/
|
||||
private stopAnswerPolling(): void {
|
||||
if (this.answerPollingInterval) {
|
||||
clearInterval(this.answerPollingInterval);
|
||||
this.answerPollingInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ICE polling
|
||||
*/
|
||||
private stopIcePolling(): void {
|
||||
if (this.icePollingInterval) {
|
||||
clearInterval(this.icePollingInterval);
|
||||
this.icePollingInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all polling
|
||||
*/
|
||||
private stopPolling(): void {
|
||||
this.stopAnswerPolling();
|
||||
this.stopIcePolling();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
*/
|
||||
on<K extends keyof RondevuConnectionEvents>(
|
||||
event: K,
|
||||
listener: RondevuConnectionEvents[K]
|
||||
): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, new Set());
|
||||
}
|
||||
this.eventListeners.get(event)!.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
off<K extends keyof RondevuConnectionEvents>(
|
||||
event: K,
|
||||
listener: RondevuConnectionEvents[K]
|
||||
): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event
|
||||
*/
|
||||
private emit<K extends keyof RondevuConnectionEvents>(
|
||||
event: K,
|
||||
...args: Parameters<RondevuConnectionEvents[K]>
|
||||
): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
listeners.forEach(listener => {
|
||||
(listener as any)(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a media track to the connection
|
||||
*/
|
||||
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
|
||||
return this.pc.addTrack(track, ...streams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection and clean up
|
||||
*/
|
||||
close(): void {
|
||||
this.stopPolling();
|
||||
this.pc.close();
|
||||
this.eventListeners.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user