diff --git a/src/peer/answering-state.ts b/src/peer/answering-state.ts new file mode 100644 index 0000000..3be457d --- /dev/null +++ b/src/peer/answering-state.ts @@ -0,0 +1,56 @@ +import { PeerState } from './state.js'; +import type { PeerOptions } from './types.js'; +import type RondevuPeer from './index.js'; + +/** + * Answering an offer and sending to server + */ +export class AnsweringState extends PeerState { + constructor(peer: RondevuPeer) { + super(peer); + } + + get name() { return 'answering'; } + + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + try { + this.peer.role = 'answerer'; + this.peer.offerId = offerId; + + // 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); + + // Send answer to server immediately (don't wait for ICE) + await this.peer.offersApi.answer(offerId, answer.sdp!); + + // Enable trickle ICE - send candidates as they arrive + this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => { + 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 exchanging ICE + const { ExchangingIceState } = await import('./exchanging-ice-state.js'); + this.peer.setState(new ExchangingIceState(this.peer, offerId, options)); + } catch (error) { + const { FailedState } = await import('./failed-state.js'); + this.peer.setState(new FailedState(this.peer, error as Error)); + throw error; + } + } +} diff --git a/src/peer/closed-state.ts b/src/peer/closed-state.ts new file mode 100644 index 0000000..04e6937 --- /dev/null +++ b/src/peer/closed-state.ts @@ -0,0 +1,12 @@ +import { PeerState } from './state.js'; + +/** + * Closed state - connection has been terminated + */ +export class ClosedState extends PeerState { + get name() { return 'closed'; } + + cleanup(): void { + this.peer.pc.close(); + } +} diff --git a/src/peer/connected-state.ts b/src/peer/connected-state.ts new file mode 100644 index 0000000..2bc2528 --- /dev/null +++ b/src/peer/connected-state.ts @@ -0,0 +1,13 @@ +import { PeerState } from './state.js'; + +/** + * Connected state - peer connection is established + */ +export 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 + } +} diff --git a/src/peer/creating-offer-state.ts b/src/peer/creating-offer-state.ts new file mode 100644 index 0000000..130ca86 --- /dev/null +++ b/src/peer/creating-offer-state.ts @@ -0,0 +1,66 @@ +import { PeerState } from './state.js'; +import type { PeerOptions } from './types.js'; +import type RondevuPeer from './index.js'; + +/** + * Creating offer and sending to server + */ +export class CreatingOfferState extends PeerState { + 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); + } + + // Create WebRTC offer + const offer = await this.peer.pc.createOffer(); + await this.peer.pc.setLocalDescription(offer); + + // Send offer to server immediately (don't wait for ICE) + 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; + + // Enable trickle ICE - send candidates as they arrive + this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => { + 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 + const { WaitingForAnswerState } = await import('./waiting-for-answer-state.js'); + this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options)); + + return offerId; + } catch (error) { + const { FailedState } = await import('./failed-state.js'); + this.peer.setState(new FailedState(this.peer, error as Error)); + throw error; + } + } +} diff --git a/src/peer/exchanging-ice-state.ts b/src/peer/exchanging-ice-state.ts new file mode 100644 index 0000000..07fbf58 --- /dev/null +++ b/src/peer/exchanging-ice-state.ts @@ -0,0 +1,74 @@ +import { PeerState } from './state.js'; +import type { PeerOptions } from './types.js'; +import type RondevuPeer from './index.js'; + +/** + * Exchanging ICE candidates and waiting for connection + */ +export 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(async () => { + this.cleanup(); + const { FailedState } = await import('./failed-state.js'); + 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(); + const { FailedState } = await import('./failed-state.js'); + 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); + } +} diff --git a/src/peer/failed-state.ts b/src/peer/failed-state.ts new file mode 100644 index 0000000..3b9234f --- /dev/null +++ b/src/peer/failed-state.ts @@ -0,0 +1,18 @@ +import { PeerState } from './state.js'; + +/** + * Failed state - connection attempt failed + */ +export class FailedState extends PeerState { + constructor(peer: any, 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(); + } +} diff --git a/src/peer/idle-state.ts b/src/peer/idle-state.ts new file mode 100644 index 0000000..f752776 --- /dev/null +++ b/src/peer/idle-state.ts @@ -0,0 +1,18 @@ +import { PeerState } from './state.js'; +import type { PeerOptions } from './types.js'; + +export class IdleState extends PeerState { + get name() { return 'idle'; } + + async createOffer(options: PeerOptions): Promise { + const { CreatingOfferState } = await import('./creating-offer-state.js'); + this.peer.setState(new CreatingOfferState(this.peer, options)); + return this.peer.state.createOffer(options); + } + + async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { + const { AnsweringState } = await import('./answering-state.js'); + this.peer.setState(new AnsweringState(this.peer)); + return this.peer.state.answer(offerId, offerSdp, options); + } +} diff --git a/src/peer/index.ts b/src/peer/index.ts index 205e128..f416bdf 100644 --- a/src/peer/index.ts +++ b/src/peer/index.ts @@ -1,395 +1,18 @@ import { RondevuOffers } from '../offers.js'; import { EventEmitter } from '../event-emitter.js'; +import type { PeerOptions, PeerEvents } from './types.js'; +import { PeerState } from './state.js'; +import { IdleState } from './idle-state.js'; +import { CreatingOfferState } from './creating-offer-state.js'; +import { WaitingForAnswerState } from './waiting-for-answer-state.js'; +import { AnsweringState } from './answering-state.js'; +import { ExchangingIceState } from './exchanging-ice-state.js'; +import { ConnectedState } from './connected-state.js'; +import { FailedState } from './failed-state.js'; +import { ClosedState } from './closed-state.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)); - return this.peer.state.answer(offerId, offerSdp, options); - } -} - -/** - * Creating offer and sending to server - */ -class CreatingOfferState extends PeerState { - 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); - } - - // Create WebRTC offer - const offer = await this.peer.pc.createOffer(); - await this.peer.pc.setLocalDescription(offer); - - // Send offer to server immediately (don't wait for ICE) - 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; - - // Enable trickle ICE - send candidates as they arrive - 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; - } - } -} - -/** - * 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 and sending to server - */ -class AnsweringState extends PeerState { - constructor(peer: RondevuPeer) { - super(peer); - } - - get name() { return 'answering'; } - - async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise { - try { - this.peer.role = 'answerer'; - this.peer.offerId = offerId; - - // 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); - - // Send answer to server immediately (don't wait for ICE) - await this.peer.offersApi.answer(offerId, answer.sdp!); - - // Enable trickle ICE - send candidates as they arrive - 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 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; - } - } -} - -/** - * 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(); - } -} +// Re-export types for external consumers +export type { PeerTimeouts, PeerOptions, PeerEvents } from './types.js'; /** * High-level WebRTC peer connection manager with state-based lifecycle @@ -521,8 +144,8 @@ export default class RondevuPeer extends EventEmitter { /** * Close the connection and clean up */ - close(): void { - this._state.close(); + async close(): Promise { + await this._state.close(); this.removeAllListeners(); } } diff --git a/src/peer/state.ts b/src/peer/state.ts new file mode 100644 index 0000000..f900d5d --- /dev/null +++ b/src/peer/state.ts @@ -0,0 +1,41 @@ +import type { PeerOptions } from './types.js'; +import type RondevuPeer from './index.js'; + +/** + * Base class for peer connection states + * Implements the State pattern for managing WebRTC connection lifecycle + */ +export 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 + } + + async close(): Promise { + this.cleanup(); + const { ClosedState } = await import('./closed-state.js'); + this.peer.setState(new ClosedState(this.peer)); + } +} diff --git a/src/peer/types.ts b/src/peer/types.ts new file mode 100644 index 0000000..f2a4a05 --- /dev/null +++ b/src/peer/types.ts @@ -0,0 +1,43 @@ +/** + * 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; +} diff --git a/src/peer/waiting-for-answer-state.ts b/src/peer/waiting-for-answer-state.ts new file mode 100644 index 0000000..39eab80 --- /dev/null +++ b/src/peer/waiting-for-answer-state.ts @@ -0,0 +1,78 @@ +import { PeerState } from './state.js'; +import type { PeerOptions } from './types.js'; +import type RondevuPeer from './index.js'; + +/** + * Waiting for answer from another peer + */ +export 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(async () => { + this.cleanup(); + const { FailedState } = await import('./failed-state.js'); + 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: any) => 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(); + const { FailedState } = await import('./failed-state.js'); + 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 + const { ExchangingIceState } = await import('./exchanging-ice-state.js'); + this.peer.setState(new ExchangingIceState(this.peer, this.offerId, this.options)); + } catch (error) { + const { FailedState } = await import('./failed-state.js'); + this.peer.setState(new FailedState(this.peer, error as Error)); + } + } + + cleanup(): void { + if (this.pollingInterval) clearInterval(this.pollingInterval); + if (this.timeout) clearTimeout(this.timeout); + } +}