diff --git a/src/connection.ts b/src/connection.ts deleted file mode 100644 index d23d4eb..0000000 --- a/src/connection.ts +++ /dev/null @@ -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; - private answerPollingInterval?: ReturnType; - private lastIceTimestamp: number = 0; // Start at 0 to get all candidates on first poll - private eventListeners: Map> = 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 { - 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 { - 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 { - 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( - 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( - event: K, - listener: RondevuConnectionEvents[K] - ): void { - const listeners = this.eventListeners.get(event); - if (listeners) { - listeners.delete(listener); - } - } - - /** - * Emit event - */ - private emit( - event: K, - ...args: Parameters - ): 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(); - } -} diff --git a/src/event-emitter.ts b/src/event-emitter.ts index 68f2f8a..7b839ea 100644 --- a/src/event-emitter.ts +++ b/src/event-emitter.ts @@ -1,17 +1,37 @@ /** - * Simple EventEmitter implementation for browser and Node.js compatibility + * Type-safe EventEmitter implementation for browser and Node.js compatibility + * + * @template EventMap - A type mapping event names to their handler signatures + * + * @example + * ```typescript + * interface MyEvents { + * 'data': (value: string) => void; + * 'error': (error: Error) => void; + * 'ready': () => void; + * } + * + * class MyClass extends EventEmitter { + * doSomething() { + * this.emit('data', 'hello'); // Type-safe! + * this.emit('error', new Error('oops')); // Type-safe! + * this.emit('ready'); // Type-safe! + * } + * } + * + * const instance = new MyClass(); + * instance.on('data', (value) => { + * console.log(value.toUpperCase()); // 'value' is typed as string + * }); + * ``` */ -export class EventEmitter { - private events: Map>; - - constructor() { - this.events = new Map(); - } +export class EventEmitter void>> { + private events: Map> = new Map(); /** * Register an event listener */ - on(event: string, listener: Function): this { + on(event: K, listener: EventMap[K]): this { if (!this.events.has(event)) { this.events.set(event, new Set()); } @@ -22,18 +42,18 @@ export class EventEmitter { /** * Register a one-time event listener */ - once(event: string, listener: Function): this { - const onceWrapper = (...args: any[]) => { - this.off(event, onceWrapper); - listener.apply(this, args); + once(event: K, listener: EventMap[K]): this { + const onceWrapper = (...args: Parameters) => { + this.off(event, onceWrapper as EventMap[K]); + listener(...args); }; - return this.on(event, onceWrapper); + return this.on(event, onceWrapper as EventMap[K]); } /** * Remove an event listener */ - off(event: string, listener: Function): this { + off(event: K, listener: EventMap[K]): this { const listeners = this.events.get(event); if (listeners) { listeners.delete(listener); @@ -47,7 +67,10 @@ export class EventEmitter { /** * Emit an event */ - emit(event: string, ...args: any[]): boolean { + protected emit( + event: K, + ...args: Parameters + ): boolean { const listeners = this.events.get(event); if (!listeners || listeners.size === 0) { return false; @@ -55,9 +78,9 @@ export class EventEmitter { listeners.forEach(listener => { try { - listener.apply(this, args); + (listener as EventMap[K])(...args); } catch (err) { - console.error(`Error in ${event} event listener:`, err); + console.error(`Error in ${String(event)} event listener:`, err); } }); @@ -67,8 +90,8 @@ export class EventEmitter { /** * Remove all listeners for an event (or all events if not specified) */ - removeAllListeners(event?: string): this { - if (event) { + removeAllListeners(event?: K): this { + if (event !== undefined) { this.events.delete(event); } else { this.events.clear(); @@ -79,7 +102,7 @@ export class EventEmitter { /** * Get listener count for an event */ - listenerCount(event: string): number { + listenerCount(event: K): number { const listeners = this.events.get(event); return listeners ? listeners.size : 0; } diff --git a/src/index.ts b/src/index.ts index 54ca941..70392b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,9 +23,10 @@ export type { // Export bloom filter export { BloomFilter } from './bloom.js'; -// Export connection manager -export { RondevuConnection } from './connection.js'; +// Export peer manager +export { default as RondevuPeer } from './peer.js'; export type { - ConnectionOptions, - RondevuConnectionEvents -} from './connection.js'; + PeerOptions, + PeerEvents, + PeerTimeouts +} from './peer.js'; diff --git a/src/offers.ts b/src/offers.ts index 921d862..b4dfae5 100644 --- a/src/offers.ts +++ b/src/offers.ts @@ -5,7 +5,6 @@ import { RondevuAuth } from './auth.js'; declare const Buffer: any; export interface CreateOfferRequest { - id?: string; sdp: string; topics: string[]; ttl?: number; @@ -24,18 +23,8 @@ export interface Offer { answeredAt?: number; } -/** - * RTCIceCandidateInit interface for environments without native WebRTC types - */ -export interface RTCIceCandidateInit { - candidate?: string; - sdpMid?: string | null; - sdpMLineIndex?: number | null; - usernameFragment?: string | null; -} - export interface IceCandidate { - candidate: RTCIceCandidateInit; // Full candidate object + candidate: any; // Full candidate object as plain JSON - don't enforce structure peerId: string; role: 'offerer' | 'answerer'; createdAt: number; @@ -290,7 +279,7 @@ export class RondevuOffers { */ async addIceCandidates( offerId: string, - candidates: RTCIceCandidateInit[] + candidates: any[] ): Promise { const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates`, { method: 'POST', diff --git a/src/peer.ts b/src/peer.ts new file mode 100644 index 0000000..b29b5d2 --- /dev/null +++ b/src/peer.ts @@ -0,0 +1,636 @@ +import { RondevuOffers } from './offers.js'; +import { EventEmitter } from './event-emitter.js'; + +/** + * Timeout configurations for different connection phases + */ +export interface PeerTimeouts { + /** Timeout for ICE gathering (default: 10000ms) */ + iceGathering?: number; + /** Timeout for waiting for answer (default: 30000ms) */ + waitingForAnswer?: number; + /** Timeout for creating answer (default: 10000ms) */ + creatingAnswer?: number; + /** Timeout for ICE connection (default: 30000ms) */ + iceConnection?: number; +} + +/** + * Options for creating a peer connection + */ +export interface PeerOptions { + /** RTCConfiguration for the peer connection */ + rtcConfig?: RTCConfiguration; + /** Topics to advertise this connection under */ + topics: string[]; + /** How long the offer should live (milliseconds) */ + ttl?: number; + /** Whether to create a data channel automatically (for offerer) */ + createDataChannel?: boolean; + /** Label for the automatically created data channel */ + dataChannelLabel?: string; + /** Timeout configurations */ + timeouts?: PeerTimeouts; +} + +/** + * Events emitted by RondevuPeer + */ +export interface PeerEvents extends Record void> { + 'state': (state: string) => void; + 'connected': () => void; + 'disconnected': () => void; + 'failed': (error: Error) => void; + 'datachannel': (channel: RTCDataChannel) => void; + 'track': (event: RTCTrackEvent) => void; +} + +/** + * Base class for peer connection states + */ +abstract class PeerState { + constructor(protected peer: RondevuPeer) {} + + abstract get name(): string; + + async createOffer(options: PeerOptions): Promise { + throw new Error(`Cannot create offer in ${this.name} state`); + } + + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + throw new Error(`Cannot answer in ${this.name} state`); + } + + async handleAnswer(sdp: string): Promise { + throw new Error(`Cannot handle answer in ${this.name} state`); + } + + async handleIceCandidate(candidate: any): Promise { + // ICE candidates can arrive in multiple states, so default is to add them + if (this.peer.pc.remoteDescription) { + await this.peer.pc.addIceCandidate(new RTCIceCandidate(candidate)); + } + } + + cleanup(): void { + // Override in states that need cleanup + } + + close(): void { + this.cleanup(); + this.peer.setState(new ClosedState(this.peer)); + } +} + +/** + * Initial idle state + */ +class IdleState extends PeerState { + get name() { return 'idle'; } + + async createOffer(options: PeerOptions): Promise { + this.peer.setState(new CreatingOfferState(this.peer, options)); + return this.peer.state.createOffer(options); + } + + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + this.peer.setState(new AnsweringState(this.peer, offerId, offerSdp, options)); + return this.peer.state.answer(offerId, offerSdp, options); + } +} + +/** + * Creating offer and gathering ICE candidates + */ +class CreatingOfferState extends PeerState { + private timeout?: ReturnType; + private pendingCandidates: any[] = []; + + constructor(peer: RondevuPeer, private options: PeerOptions) { + super(peer); + } + + get name() { return 'creating-offer'; } + + async createOffer(options: PeerOptions): Promise { + try { + this.peer.role = 'offerer'; + + // Create data channel if requested + if (options.createDataChannel !== false) { + const channel = this.peer.pc.createDataChannel( + options.dataChannelLabel || 'data' + ); + this.peer.emitEvent('datachannel', channel); + } + + // Set up ICE candidate buffering + this.peer.pc.onicecandidate = (event) => { + if (event.candidate) { + const candidateData = event.candidate.toJSON(); + if (candidateData.candidate && candidateData.candidate !== '') { + this.pendingCandidates.push(candidateData); + } + } + }; + + // Create WebRTC offer + const offer = await this.peer.pc.createOffer(); + await this.peer.pc.setLocalDescription(offer); + + // Wait for ICE gathering to complete (or timeout) + const iceTimeout = options.timeouts?.iceGathering || 10000; + await this.waitForIceGathering(iceTimeout); + + // Create offer on Rondevu server (server generates hash-based ID) + const offers = await this.peer.offersApi.create([{ + sdp: offer.sdp!, + topics: options.topics, + ttl: options.ttl || 300000 + }]); + + const offerId = offers[0].id; + this.peer.offerId = offerId; + + // Send buffered ICE candidates + if (this.pendingCandidates.length > 0) { + await this.peer.offersApi.addIceCandidates(offerId, this.pendingCandidates); + this.pendingCandidates = []; + } + + // Enable trickle ICE for future candidates + this.peer.pc.onicecandidate = async (event) => { + if (event.candidate && offerId) { + const candidateData = event.candidate.toJSON(); + if (candidateData.candidate && candidateData.candidate !== '') { + try { + await this.peer.offersApi.addIceCandidates(offerId, [candidateData]); + } catch (err) { + console.error('Error sending ICE candidate:', err); + } + } + } + }; + + // Transition to waiting for answer + this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options)); + + return offerId; + } catch (error) { + this.peer.setState(new FailedState(this.peer, error as Error)); + throw error; + } + } + + private async waitForIceGathering(timeout: number): Promise { + return new Promise((resolve, reject) => { + const checkState = () => { + if (this.peer.pc.iceGatheringState === 'complete') { + if (this.timeout) clearTimeout(this.timeout); + resolve(); + } + }; + + this.peer.pc.onicegatheringstatechange = checkState; + checkState(); // Check immediately in case already complete + + this.timeout = setTimeout(() => { + // Timeout is not fatal - we proceed with candidates we have + console.warn('ICE gathering timeout - proceeding with gathered candidates'); + resolve(); + }, timeout); + }); + } + + cleanup(): void { + if (this.timeout) clearTimeout(this.timeout); + } +} + +/** + * Waiting for answer from another peer + */ +class WaitingForAnswerState extends PeerState { + private pollingInterval?: ReturnType; + private timeout?: ReturnType; + + constructor( + peer: RondevuPeer, + private offerId: string, + private options: PeerOptions + ) { + super(peer); + this.startPolling(); + } + + get name() { return 'waiting-for-answer'; } + + private startPolling(): void { + const answerTimeout = this.options.timeouts?.waitingForAnswer || 30000; + + this.timeout = setTimeout(() => { + this.cleanup(); + this.peer.setState(new FailedState( + this.peer, + new Error('Timeout waiting for answer') + )); + }, answerTimeout); + + this.pollingInterval = setInterval(async () => { + try { + const answers = await this.peer.offersApi.getAnswers(); + const myAnswer = answers.find(a => a.offerId === this.offerId); + + if (myAnswer) { + this.cleanup(); + await this.handleAnswer(myAnswer.sdp); + } + } catch (err) { + console.error('Error polling for answers:', err); + if (err instanceof Error && err.message.includes('not found')) { + this.cleanup(); + this.peer.setState(new FailedState( + this.peer, + new Error('Offer expired or not found') + )); + } + } + }, 2000); + } + + async handleAnswer(sdp: string): Promise { + try { + await this.peer.pc.setRemoteDescription({ + type: 'answer', + sdp + }); + + // Transition to exchanging ICE + this.peer.setState(new ExchangingIceState(this.peer, this.offerId, this.options)); + } catch (error) { + this.peer.setState(new FailedState(this.peer, error as Error)); + } + } + + cleanup(): void { + if (this.pollingInterval) clearInterval(this.pollingInterval); + if (this.timeout) clearTimeout(this.timeout); + } +} + +/** + * Answering an offer from another peer + */ +class AnsweringState extends PeerState { + private timeout?: ReturnType; + private pendingCandidates: any[] = []; + + constructor( + peer: RondevuPeer, + private offerId: string, + private offerSdp: string, + private options: PeerOptions + ) { + super(peer); + } + + get name() { return 'answering'; } + + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + try { + this.peer.role = 'answerer'; + this.peer.offerId = offerId; + + const answerTimeout = options.timeouts?.creatingAnswer || 10000; + + this.timeout = setTimeout(() => { + this.peer.setState(new FailedState( + this.peer, + new Error('Timeout creating answer') + )); + }, answerTimeout); + + // Buffer ICE candidates during answer creation + this.peer.pc.onicecandidate = (event) => { + if (event.candidate) { + const candidateData = event.candidate.toJSON(); + if (candidateData.candidate && candidateData.candidate !== '') { + this.pendingCandidates.push(candidateData); + } + } + }; + + // Set remote description + await this.peer.pc.setRemoteDescription({ + type: 'offer', + sdp: offerSdp + }); + + // Create answer + const answer = await this.peer.pc.createAnswer(); + await this.peer.pc.setLocalDescription(answer); + + // Wait for ICE gathering + const iceTimeout = options.timeouts?.iceGathering || 10000; + await this.waitForIceGathering(iceTimeout); + + // Send answer to server FIRST + await this.peer.offersApi.answer(offerId, answer.sdp!); + + // Send buffered ICE candidates + if (this.pendingCandidates.length > 0) { + await this.peer.offersApi.addIceCandidates(offerId, this.pendingCandidates); + this.pendingCandidates = []; + } + + // Enable trickle ICE + this.peer.pc.onicecandidate = async (event) => { + if (event.candidate && offerId) { + const candidateData = event.candidate.toJSON(); + if (candidateData.candidate && candidateData.candidate !== '') { + try { + await this.peer.offersApi.addIceCandidates(offerId, [candidateData]); + } catch (err) { + console.error('Error sending ICE candidate:', err); + } + } + } + }; + + if (this.timeout) clearTimeout(this.timeout); + + // Transition to exchanging ICE + this.peer.setState(new ExchangingIceState(this.peer, offerId, options)); + } catch (error) { + this.peer.setState(new FailedState(this.peer, error as Error)); + throw error; + } + } + + private async waitForIceGathering(timeout: number): Promise { + return new Promise((resolve) => { + const checkState = () => { + if (this.peer.pc.iceGatheringState === 'complete') { + resolve(); + } + }; + + this.peer.pc.onicegatheringstatechange = checkState; + checkState(); + + setTimeout(() => { + console.warn('ICE gathering timeout - proceeding with gathered candidates'); + resolve(); + }, timeout); + }); + } + + cleanup(): void { + if (this.timeout) clearTimeout(this.timeout); + } +} + +/** + * Exchanging ICE candidates and waiting for connection + */ +class ExchangingIceState extends PeerState { + private pollingInterval?: ReturnType; + private timeout?: ReturnType; + private lastIceTimestamp = 0; + + constructor( + peer: RondevuPeer, + private offerId: string, + private options: PeerOptions + ) { + super(peer); + this.startPolling(); + } + + get name() { return 'exchanging-ice'; } + + private startPolling(): void { + const connectionTimeout = this.options.timeouts?.iceConnection || 30000; + + this.timeout = setTimeout(() => { + this.cleanup(); + this.peer.setState(new FailedState( + this.peer, + new Error('ICE connection timeout') + )); + }, connectionTimeout); + + this.pollingInterval = setInterval(async () => { + try { + const candidates = await this.peer.offersApi.getIceCandidates( + this.offerId, + this.lastIceTimestamp + ); + + for (const cand of candidates) { + if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') { + try { + await this.peer.pc.addIceCandidate(new RTCIceCandidate(cand.candidate)); + this.lastIceTimestamp = cand.createdAt; + } catch (err) { + console.warn('Failed to add ICE candidate:', err); + this.lastIceTimestamp = cand.createdAt; + } + } else { + this.lastIceTimestamp = cand.createdAt; + } + } + } catch (err) { + console.error('Error polling for ICE candidates:', err); + if (err instanceof Error && err.message.includes('not found')) { + this.cleanup(); + this.peer.setState(new FailedState( + this.peer, + new Error('Offer expired or not found') + )); + } + } + }, 1000); + } + + cleanup(): void { + if (this.pollingInterval) clearInterval(this.pollingInterval); + if (this.timeout) clearTimeout(this.timeout); + } +} + +/** + * Successfully connected state + */ +class ConnectedState extends PeerState { + get name() { return 'connected'; } + + cleanup(): void { + // Keep connection alive, but stop any polling + // The peer connection will handle disconnects via onconnectionstatechange + } +} + +/** + * Failed state + */ +class FailedState extends PeerState { + constructor(peer: RondevuPeer, private error: Error) { + super(peer); + peer.emitEvent('failed', error); + } + + get name() { return 'failed'; } + + cleanup(): void { + // Connection is failed, clean up resources + this.peer.pc.close(); + } +} + +/** + * Closed state + */ +class ClosedState extends PeerState { + get name() { return 'closed'; } + + cleanup(): void { + this.peer.pc.close(); + } +} + +/** + * High-level WebRTC peer connection manager with state-based lifecycle + * Handles offer/answer exchange, ICE candidates, timeouts, and error recovery + */ +export default class RondevuPeer extends EventEmitter { + pc: RTCPeerConnection; + offersApi: RondevuOffers; + offerId?: string; + role?: 'offerer' | 'answerer'; + + private _state: PeerState; + + /** + * Current connection state name + */ + get stateName(): string { + return this._state.name; + } + + /** + * Current state object (internal use) + */ + get state(): PeerState { + return this._state; + } + + /** + * RTCPeerConnection state + */ + get connectionState(): RTCPeerConnectionState { + return this.pc.connectionState; + } + + constructor( + offersApi: RondevuOffers, + rtcConfig: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + } + ) { + super(); + this.offersApi = offersApi; + this.pc = new RTCPeerConnection(rtcConfig); + this._state = new IdleState(this); + + this.setupPeerConnection(); + } + + /** + * Set up peer connection event handlers + */ + private setupPeerConnection(): void { + this.pc.onconnectionstatechange = () => { + switch (this.pc.connectionState) { + case 'connected': + this.setState(new ConnectedState(this)); + this.emitEvent('connected'); + break; + case 'disconnected': + this.emitEvent('disconnected'); + break; + case 'failed': + this.setState(new FailedState(this, new Error('Connection failed'))); + break; + case 'closed': + this.setState(new ClosedState(this)); + this.emitEvent('disconnected'); + break; + } + }; + + this.pc.ondatachannel = (event) => { + this.emitEvent('datachannel', event.channel); + }; + + this.pc.ontrack = (event) => { + this.emitEvent('track', event); + }; + + this.pc.onicecandidateerror = (event) => { + console.error('ICE candidate error:', event); + }; + } + + /** + * Set new state and emit state change event + */ + setState(newState: PeerState): void { + this._state.cleanup(); + this._state = newState; + this.emitEvent('state', newState.name); + } + + /** + * Emit event (exposed for PeerState classes) + * @internal + */ + emitEvent( + event: K, + ...args: Parameters + ): void { + this.emit(event, ...args); + } + + /** + * Create an offer and advertise on topics + */ + async createOffer(options: PeerOptions): Promise { + return this._state.createOffer(options); + } + + /** + * Answer an existing offer + */ + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + return this._state.answer(offerId, offerSdp, options); + } + + /** + * 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._state.close(); + this.removeAllListeners(); + } +} diff --git a/src/rondevu.ts b/src/rondevu.ts index 4e406ad..f9960ac 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -1,6 +1,6 @@ import { RondevuAuth, Credentials, FetchFunction } from './auth.js'; import { RondevuOffers } from './offers.js'; -import { RondevuConnection, ConnectionOptions } from './connection.js'; +import RondevuPeer from './peer.js'; export interface RondevuOptions { /** @@ -87,17 +87,17 @@ export class Rondevu { } /** - * Create a new WebRTC connection (requires authentication) - * This is a high-level helper that creates and manages WebRTC connections + * Create a new WebRTC peer connection (requires authentication) + * This is a high-level helper that creates and manages WebRTC connections with state management * * @param rtcConfig Optional RTCConfiguration for the peer connection - * @returns RondevuConnection instance + * @returns RondevuPeer instance */ - createConnection(rtcConfig?: RTCConfiguration): RondevuConnection { + createPeer(rtcConfig?: RTCConfiguration): RondevuPeer { if (!this._offers) { throw new Error('Not authenticated. Call register() first or provide credentials.'); } - return new RondevuConnection(this._offers, rtcConfig); + return new RondevuPeer(this._offers, rtcConfig); } }