mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 02:43:25 +00:00
Refactor peer connection state machine into separate files
Split the monolithic peer.ts file into a modular state-based architecture: - Created separate files for each state class (idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed) - Extracted shared types into types.ts - Extracted base PeerState class into state.ts - Updated peer/index.ts to import state classes instead of defining them inline - Made close() method async to support dynamic imports and avoid circular dependencies - Used dynamic imports in state transitions to prevent circular dependency issues This improves code organization, maintainability, and makes each state's logic easier to understand and test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
56
src/peer/answering-state.ts
Normal file
56
src/peer/answering-state.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/peer/closed-state.ts
Normal file
12
src/peer/closed-state.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/peer/connected-state.ts
Normal file
13
src/peer/connected-state.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/peer/creating-offer-state.ts
Normal file
66
src/peer/creating-offer-state.ts
Normal file
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/peer/exchanging-ice-state.ts
Normal file
74
src/peer/exchanging-ice-state.ts
Normal file
@@ -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<typeof setInterval>;
|
||||||
|
private timeout?: ReturnType<typeof setTimeout>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/peer/failed-state.ts
Normal file
18
src/peer/failed-state.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/peer/idle-state.ts
Normal file
18
src/peer/idle-state.ts
Normal file
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
const { AnsweringState } = await import('./answering-state.js');
|
||||||
|
this.peer.setState(new AnsweringState(this.peer));
|
||||||
|
return this.peer.state.answer(offerId, offerSdp, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,395 +1,18 @@
|
|||||||
import { RondevuOffers } from '../offers.js';
|
import { RondevuOffers } from '../offers.js';
|
||||||
import { EventEmitter } from '../event-emitter.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';
|
||||||
|
|
||||||
/**
|
// Re-export types for external consumers
|
||||||
* Timeout configurations for different connection phases
|
export type { PeerTimeouts, PeerOptions, PeerEvents } from './types.js';
|
||||||
*/
|
|
||||||
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<string, (...args: any[]) => 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<string> {
|
|
||||||
throw new Error(`Cannot create offer in ${this.name} state`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
|
|
||||||
throw new Error(`Cannot answer in ${this.name} state`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleAnswer(sdp: string): Promise<void> {
|
|
||||||
throw new Error(`Cannot handle answer in ${this.name} state`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleIceCandidate(candidate: any): Promise<void> {
|
|
||||||
// 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<string> {
|
|
||||||
this.peer.setState(new CreatingOfferState(this.peer, options));
|
|
||||||
return this.peer.state.createOffer(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
|
|
||||||
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<string> {
|
|
||||||
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<typeof setInterval>;
|
|
||||||
private timeout?: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<typeof setInterval>;
|
|
||||||
private timeout?: ReturnType<typeof setTimeout>;
|
|
||||||
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
|
* High-level WebRTC peer connection manager with state-based lifecycle
|
||||||
@@ -521,8 +144,8 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
|
|||||||
/**
|
/**
|
||||||
* Close the connection and clean up
|
* Close the connection and clean up
|
||||||
*/
|
*/
|
||||||
close(): void {
|
async close(): Promise<void> {
|
||||||
this._state.close();
|
await this._state.close();
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/peer/state.ts
Normal file
41
src/peer/state.ts
Normal file
@@ -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<string> {
|
||||||
|
throw new Error(`Cannot create offer in ${this.name} state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
|
||||||
|
throw new Error(`Cannot answer in ${this.name} state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAnswer(sdp: string): Promise<void> {
|
||||||
|
throw new Error(`Cannot handle answer in ${this.name} state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleIceCandidate(candidate: any): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
this.cleanup();
|
||||||
|
const { ClosedState } = await import('./closed-state.js');
|
||||||
|
this.peer.setState(new ClosedState(this.peer));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/peer/types.ts
Normal file
43
src/peer/types.ts
Normal file
@@ -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<string, (...args: any[]) => void> {
|
||||||
|
'state': (state: string) => void;
|
||||||
|
'connected': () => void;
|
||||||
|
'disconnected': () => void;
|
||||||
|
'failed': (error: Error) => void;
|
||||||
|
'datachannel': (channel: RTCDataChannel) => void;
|
||||||
|
'track': (event: RTCTrackEvent) => void;
|
||||||
|
}
|
||||||
78
src/peer/waiting-for-answer-state.ts
Normal file
78
src/peer/waiting-for-answer-state.ts
Normal file
@@ -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<typeof setInterval>;
|
||||||
|
private timeout?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user