@xtr-dev/rondevu-client 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/peer/answering-state.d.ts +11 -0
- package/dist/peer/answering-state.js +36 -0
- package/dist/peer/closed-state.d.ts +8 -0
- package/dist/peer/closed-state.js +10 -0
- package/dist/peer/connected-state.d.ts +8 -0
- package/dist/peer/connected-state.js +11 -0
- package/dist/peer/creating-offer-state.d.ts +12 -0
- package/dist/peer/creating-offer-state.js +43 -0
- package/dist/peer/exchanging-ice-state.d.ts +17 -0
- package/dist/peer/exchanging-ice-state.js +56 -0
- package/dist/peer/failed-state.d.ts +10 -0
- package/dist/peer/failed-state.js +16 -0
- package/dist/peer/idle-state.d.ts +7 -0
- package/dist/peer/idle-state.js +14 -0
- package/dist/peer/index.d.ts +8 -57
- package/dist/peer/index.js +27 -289
- package/dist/peer/state.d.ts +23 -0
- package/dist/peer/state.js +55 -0
- package/dist/peer/types.d.ts +41 -0
- package/dist/peer/types.js +1 -0
- package/dist/peer/waiting-for-answer-state.d.ts +17 -0
- package/dist/peer/waiting-for-answer-state.js +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
import type { PeerOptions } from './types.js';
|
|
3
|
+
import type RondevuPeer from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Answering an offer and sending to server
|
|
6
|
+
*/
|
|
7
|
+
export declare class AnsweringState extends PeerState {
|
|
8
|
+
constructor(peer: RondevuPeer);
|
|
9
|
+
get name(): string;
|
|
10
|
+
answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Answering an offer and sending to server
|
|
4
|
+
*/
|
|
5
|
+
export class AnsweringState extends PeerState {
|
|
6
|
+
constructor(peer) {
|
|
7
|
+
super(peer);
|
|
8
|
+
}
|
|
9
|
+
get name() { return 'answering'; }
|
|
10
|
+
async answer(offerId, offerSdp, options) {
|
|
11
|
+
try {
|
|
12
|
+
this.peer.role = 'answerer';
|
|
13
|
+
this.peer.offerId = offerId;
|
|
14
|
+
// Set remote description
|
|
15
|
+
await this.peer.pc.setRemoteDescription({
|
|
16
|
+
type: 'offer',
|
|
17
|
+
sdp: offerSdp
|
|
18
|
+
});
|
|
19
|
+
// Create answer
|
|
20
|
+
const answer = await this.peer.pc.createAnswer();
|
|
21
|
+
await this.peer.pc.setLocalDescription(answer);
|
|
22
|
+
// Send answer to server immediately (don't wait for ICE)
|
|
23
|
+
await this.peer.offersApi.answer(offerId, answer.sdp);
|
|
24
|
+
// Enable trickle ICE - send candidates as they arrive
|
|
25
|
+
this.setupIceCandidateHandler(offerId);
|
|
26
|
+
// Transition to exchanging ICE
|
|
27
|
+
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
|
|
28
|
+
this.peer.setState(new ExchangingIceState(this.peer, offerId, options));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const { FailedState } = await import('./failed-state.js');
|
|
32
|
+
this.peer.setState(new FailedState(this.peer, error));
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Connected state - peer connection is established
|
|
4
|
+
*/
|
|
5
|
+
export class ConnectedState extends PeerState {
|
|
6
|
+
get name() { return 'connected'; }
|
|
7
|
+
cleanup() {
|
|
8
|
+
// Keep connection alive, but stop any polling
|
|
9
|
+
// The peer connection will handle disconnects via onconnectionstatechange
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
import type { PeerOptions } from './types.js';
|
|
3
|
+
import type RondevuPeer from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Creating offer and sending to server
|
|
6
|
+
*/
|
|
7
|
+
export declare class CreatingOfferState extends PeerState {
|
|
8
|
+
private options;
|
|
9
|
+
constructor(peer: RondevuPeer, options: PeerOptions);
|
|
10
|
+
get name(): string;
|
|
11
|
+
createOffer(options: PeerOptions): Promise<string>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creating offer and sending to server
|
|
4
|
+
*/
|
|
5
|
+
export class CreatingOfferState extends PeerState {
|
|
6
|
+
constructor(peer, options) {
|
|
7
|
+
super(peer);
|
|
8
|
+
this.options = options;
|
|
9
|
+
}
|
|
10
|
+
get name() { return 'creating-offer'; }
|
|
11
|
+
async createOffer(options) {
|
|
12
|
+
try {
|
|
13
|
+
this.peer.role = 'offerer';
|
|
14
|
+
// Create data channel if requested
|
|
15
|
+
if (options.createDataChannel !== false) {
|
|
16
|
+
const channel = this.peer.pc.createDataChannel(options.dataChannelLabel || 'data');
|
|
17
|
+
this.peer.emitEvent('datachannel', channel);
|
|
18
|
+
}
|
|
19
|
+
// Create WebRTC offer
|
|
20
|
+
const offer = await this.peer.pc.createOffer();
|
|
21
|
+
await this.peer.pc.setLocalDescription(offer);
|
|
22
|
+
// Send offer to server immediately (don't wait for ICE)
|
|
23
|
+
const offers = await this.peer.offersApi.create([{
|
|
24
|
+
sdp: offer.sdp,
|
|
25
|
+
topics: options.topics,
|
|
26
|
+
ttl: options.ttl || 300000
|
|
27
|
+
}]);
|
|
28
|
+
const offerId = offers[0].id;
|
|
29
|
+
this.peer.offerId = offerId;
|
|
30
|
+
// Enable trickle ICE - send candidates as they arrive
|
|
31
|
+
this.setupIceCandidateHandler(offerId);
|
|
32
|
+
// Transition to waiting for answer
|
|
33
|
+
const { WaitingForAnswerState } = await import('./waiting-for-answer-state.js');
|
|
34
|
+
this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options));
|
|
35
|
+
return offerId;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const { FailedState } = await import('./failed-state.js');
|
|
39
|
+
this.peer.setState(new FailedState(this.peer, error));
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
import type { PeerOptions } from './types.js';
|
|
3
|
+
import type RondevuPeer from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Exchanging ICE candidates and waiting for connection
|
|
6
|
+
*/
|
|
7
|
+
export declare class ExchangingIceState extends PeerState {
|
|
8
|
+
private offerId;
|
|
9
|
+
private options;
|
|
10
|
+
private pollingInterval?;
|
|
11
|
+
private timeout?;
|
|
12
|
+
private lastIceTimestamp;
|
|
13
|
+
constructor(peer: RondevuPeer, offerId: string, options: PeerOptions);
|
|
14
|
+
get name(): string;
|
|
15
|
+
private startPolling;
|
|
16
|
+
cleanup(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Exchanging ICE candidates and waiting for connection
|
|
4
|
+
*/
|
|
5
|
+
export class ExchangingIceState extends PeerState {
|
|
6
|
+
constructor(peer, offerId, options) {
|
|
7
|
+
super(peer);
|
|
8
|
+
this.offerId = offerId;
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.lastIceTimestamp = 0;
|
|
11
|
+
this.startPolling();
|
|
12
|
+
}
|
|
13
|
+
get name() { return 'exchanging-ice'; }
|
|
14
|
+
startPolling() {
|
|
15
|
+
const connectionTimeout = this.options.timeouts?.iceConnection || 30000;
|
|
16
|
+
this.timeout = setTimeout(async () => {
|
|
17
|
+
this.cleanup();
|
|
18
|
+
const { FailedState } = await import('./failed-state.js');
|
|
19
|
+
this.peer.setState(new FailedState(this.peer, new Error('ICE connection timeout')));
|
|
20
|
+
}, connectionTimeout);
|
|
21
|
+
this.pollingInterval = setInterval(async () => {
|
|
22
|
+
try {
|
|
23
|
+
const candidates = await this.peer.offersApi.getIceCandidates(this.offerId, this.lastIceTimestamp);
|
|
24
|
+
for (const cand of candidates) {
|
|
25
|
+
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
|
|
26
|
+
try {
|
|
27
|
+
await this.peer.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
|
|
28
|
+
this.lastIceTimestamp = cand.createdAt;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.warn('Failed to add ICE candidate:', err);
|
|
32
|
+
this.lastIceTimestamp = cand.createdAt;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.lastIceTimestamp = cand.createdAt;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error('Error polling for ICE candidates:', err);
|
|
42
|
+
if (err instanceof Error && err.message.includes('not found')) {
|
|
43
|
+
this.cleanup();
|
|
44
|
+
const { FailedState } = await import('./failed-state.js');
|
|
45
|
+
this.peer.setState(new FailedState(this.peer, new Error('Offer expired or not found')));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, 1000);
|
|
49
|
+
}
|
|
50
|
+
cleanup() {
|
|
51
|
+
if (this.pollingInterval)
|
|
52
|
+
clearInterval(this.pollingInterval);
|
|
53
|
+
if (this.timeout)
|
|
54
|
+
clearTimeout(this.timeout);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Failed state - connection attempt failed
|
|
4
|
+
*/
|
|
5
|
+
export class FailedState extends PeerState {
|
|
6
|
+
constructor(peer, error) {
|
|
7
|
+
super(peer);
|
|
8
|
+
this.error = error;
|
|
9
|
+
peer.emitEvent('failed', error);
|
|
10
|
+
}
|
|
11
|
+
get name() { return 'failed'; }
|
|
12
|
+
cleanup() {
|
|
13
|
+
// Connection is failed, clean up resources
|
|
14
|
+
this.peer.pc.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
import type { PeerOptions } from './types.js';
|
|
3
|
+
export declare class IdleState extends PeerState {
|
|
4
|
+
get name(): string;
|
|
5
|
+
createOffer(options: PeerOptions): Promise<string>;
|
|
6
|
+
answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
export class IdleState extends PeerState {
|
|
3
|
+
get name() { return 'idle'; }
|
|
4
|
+
async createOffer(options) {
|
|
5
|
+
const { CreatingOfferState } = await import('./creating-offer-state.js');
|
|
6
|
+
this.peer.setState(new CreatingOfferState(this.peer, options));
|
|
7
|
+
return this.peer.state.createOffer(options);
|
|
8
|
+
}
|
|
9
|
+
async answer(offerId, offerSdp, options) {
|
|
10
|
+
const { AnsweringState } = await import('./answering-state.js');
|
|
11
|
+
this.peer.setState(new AnsweringState(this.peer));
|
|
12
|
+
return this.peer.state.answer(offerId, offerSdp, options);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/peer/index.d.ts
CHANGED
|
@@ -1,60 +1,8 @@
|
|
|
1
1
|
import { RondevuOffers } from '../offers.js';
|
|
2
2
|
import { EventEmitter } from '../event-emitter.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export interface PeerTimeouts {
|
|
7
|
-
/** Timeout for ICE gathering (default: 10000ms) */
|
|
8
|
-
iceGathering?: number;
|
|
9
|
-
/** Timeout for waiting for answer (default: 30000ms) */
|
|
10
|
-
waitingForAnswer?: number;
|
|
11
|
-
/** Timeout for creating answer (default: 10000ms) */
|
|
12
|
-
creatingAnswer?: number;
|
|
13
|
-
/** Timeout for ICE connection (default: 30000ms) */
|
|
14
|
-
iceConnection?: number;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Options for creating a peer connection
|
|
18
|
-
*/
|
|
19
|
-
export interface PeerOptions {
|
|
20
|
-
/** RTCConfiguration for the peer connection */
|
|
21
|
-
rtcConfig?: RTCConfiguration;
|
|
22
|
-
/** Topics to advertise this connection under */
|
|
23
|
-
topics: string[];
|
|
24
|
-
/** How long the offer should live (milliseconds) */
|
|
25
|
-
ttl?: number;
|
|
26
|
-
/** Whether to create a data channel automatically (for offerer) */
|
|
27
|
-
createDataChannel?: boolean;
|
|
28
|
-
/** Label for the automatically created data channel */
|
|
29
|
-
dataChannelLabel?: string;
|
|
30
|
-
/** Timeout configurations */
|
|
31
|
-
timeouts?: PeerTimeouts;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Events emitted by RondevuPeer
|
|
35
|
-
*/
|
|
36
|
-
export interface PeerEvents extends Record<string, (...args: any[]) => void> {
|
|
37
|
-
'state': (state: string) => void;
|
|
38
|
-
'connected': () => void;
|
|
39
|
-
'disconnected': () => void;
|
|
40
|
-
'failed': (error: Error) => void;
|
|
41
|
-
'datachannel': (channel: RTCDataChannel) => void;
|
|
42
|
-
'track': (event: RTCTrackEvent) => void;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Base class for peer connection states
|
|
46
|
-
*/
|
|
47
|
-
declare abstract class PeerState {
|
|
48
|
-
protected peer: RondevuPeer;
|
|
49
|
-
constructor(peer: RondevuPeer);
|
|
50
|
-
abstract get name(): string;
|
|
51
|
-
createOffer(options: PeerOptions): Promise<string>;
|
|
52
|
-
answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void>;
|
|
53
|
-
handleAnswer(sdp: string): Promise<void>;
|
|
54
|
-
handleIceCandidate(candidate: any): Promise<void>;
|
|
55
|
-
cleanup(): void;
|
|
56
|
-
close(): void;
|
|
57
|
-
}
|
|
3
|
+
import type { PeerOptions, PeerEvents } from './types.js';
|
|
4
|
+
import { PeerState } from './state.js';
|
|
5
|
+
export type { PeerTimeouts, PeerOptions, PeerEvents } from './types.js';
|
|
58
6
|
/**
|
|
59
7
|
* High-level WebRTC peer connection manager with state-based lifecycle
|
|
60
8
|
* Handles offer/answer exchange, ICE candidates, timeouts, and error recovery
|
|
@@ -65,6 +13,10 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
|
|
|
65
13
|
offerId?: string;
|
|
66
14
|
role?: 'offerer' | 'answerer';
|
|
67
15
|
private _state;
|
|
16
|
+
private connectionStateChangeHandler?;
|
|
17
|
+
private dataChannelHandler?;
|
|
18
|
+
private trackHandler?;
|
|
19
|
+
private iceCandidateErrorHandler?;
|
|
68
20
|
/**
|
|
69
21
|
* Current connection state name
|
|
70
22
|
*/
|
|
@@ -106,6 +58,5 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
|
|
|
106
58
|
/**
|
|
107
59
|
* Close the connection and clean up
|
|
108
60
|
*/
|
|
109
|
-
close(): void
|
|
61
|
+
close(): Promise<void>;
|
|
110
62
|
}
|
|
111
|
-
export {};
|
package/dist/peer/index.js
CHANGED
|
@@ -1,287 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from '../event-emitter.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
constructor(peer) {
|
|
7
|
-
this.peer = peer;
|
|
8
|
-
}
|
|
9
|
-
async createOffer(options) {
|
|
10
|
-
throw new Error(`Cannot create offer in ${this.name} state`);
|
|
11
|
-
}
|
|
12
|
-
async answer(offerId, offerSdp, options) {
|
|
13
|
-
throw new Error(`Cannot answer in ${this.name} state`);
|
|
14
|
-
}
|
|
15
|
-
async handleAnswer(sdp) {
|
|
16
|
-
throw new Error(`Cannot handle answer in ${this.name} state`);
|
|
17
|
-
}
|
|
18
|
-
async handleIceCandidate(candidate) {
|
|
19
|
-
// ICE candidates can arrive in multiple states, so default is to add them
|
|
20
|
-
if (this.peer.pc.remoteDescription) {
|
|
21
|
-
await this.peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
cleanup() {
|
|
25
|
-
// Override in states that need cleanup
|
|
26
|
-
}
|
|
27
|
-
close() {
|
|
28
|
-
this.cleanup();
|
|
29
|
-
this.peer.setState(new ClosedState(this.peer));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Initial idle state
|
|
34
|
-
*/
|
|
35
|
-
class IdleState extends PeerState {
|
|
36
|
-
get name() { return 'idle'; }
|
|
37
|
-
async createOffer(options) {
|
|
38
|
-
this.peer.setState(new CreatingOfferState(this.peer, options));
|
|
39
|
-
return this.peer.state.createOffer(options);
|
|
40
|
-
}
|
|
41
|
-
async answer(offerId, offerSdp, options) {
|
|
42
|
-
this.peer.setState(new AnsweringState(this.peer));
|
|
43
|
-
return this.peer.state.answer(offerId, offerSdp, options);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Creating offer and sending to server
|
|
48
|
-
*/
|
|
49
|
-
class CreatingOfferState extends PeerState {
|
|
50
|
-
constructor(peer, options) {
|
|
51
|
-
super(peer);
|
|
52
|
-
this.options = options;
|
|
53
|
-
}
|
|
54
|
-
get name() { return 'creating-offer'; }
|
|
55
|
-
async createOffer(options) {
|
|
56
|
-
try {
|
|
57
|
-
this.peer.role = 'offerer';
|
|
58
|
-
// Create data channel if requested
|
|
59
|
-
if (options.createDataChannel !== false) {
|
|
60
|
-
const channel = this.peer.pc.createDataChannel(options.dataChannelLabel || 'data');
|
|
61
|
-
this.peer.emitEvent('datachannel', channel);
|
|
62
|
-
}
|
|
63
|
-
// Create WebRTC offer
|
|
64
|
-
const offer = await this.peer.pc.createOffer();
|
|
65
|
-
await this.peer.pc.setLocalDescription(offer);
|
|
66
|
-
// Send offer to server immediately (don't wait for ICE)
|
|
67
|
-
const offers = await this.peer.offersApi.create([{
|
|
68
|
-
sdp: offer.sdp,
|
|
69
|
-
topics: options.topics,
|
|
70
|
-
ttl: options.ttl || 300000
|
|
71
|
-
}]);
|
|
72
|
-
const offerId = offers[0].id;
|
|
73
|
-
this.peer.offerId = offerId;
|
|
74
|
-
// Enable trickle ICE - send candidates as they arrive
|
|
75
|
-
this.peer.pc.onicecandidate = async (event) => {
|
|
76
|
-
if (event.candidate && offerId) {
|
|
77
|
-
const candidateData = event.candidate.toJSON();
|
|
78
|
-
if (candidateData.candidate && candidateData.candidate !== '') {
|
|
79
|
-
try {
|
|
80
|
-
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
|
|
81
|
-
}
|
|
82
|
-
catch (err) {
|
|
83
|
-
console.error('Error sending ICE candidate:', err);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
// Transition to waiting for answer
|
|
89
|
-
this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options));
|
|
90
|
-
return offerId;
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
this.peer.setState(new FailedState(this.peer, error));
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Waiting for answer from another peer
|
|
100
|
-
*/
|
|
101
|
-
class WaitingForAnswerState extends PeerState {
|
|
102
|
-
constructor(peer, offerId, options) {
|
|
103
|
-
super(peer);
|
|
104
|
-
this.offerId = offerId;
|
|
105
|
-
this.options = options;
|
|
106
|
-
this.startPolling();
|
|
107
|
-
}
|
|
108
|
-
get name() { return 'waiting-for-answer'; }
|
|
109
|
-
startPolling() {
|
|
110
|
-
const answerTimeout = this.options.timeouts?.waitingForAnswer || 30000;
|
|
111
|
-
this.timeout = setTimeout(() => {
|
|
112
|
-
this.cleanup();
|
|
113
|
-
this.peer.setState(new FailedState(this.peer, new Error('Timeout waiting for answer')));
|
|
114
|
-
}, answerTimeout);
|
|
115
|
-
this.pollingInterval = setInterval(async () => {
|
|
116
|
-
try {
|
|
117
|
-
const answers = await this.peer.offersApi.getAnswers();
|
|
118
|
-
const myAnswer = answers.find(a => a.offerId === this.offerId);
|
|
119
|
-
if (myAnswer) {
|
|
120
|
-
this.cleanup();
|
|
121
|
-
await this.handleAnswer(myAnswer.sdp);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
console.error('Error polling for answers:', err);
|
|
126
|
-
if (err instanceof Error && err.message.includes('not found')) {
|
|
127
|
-
this.cleanup();
|
|
128
|
-
this.peer.setState(new FailedState(this.peer, new Error('Offer expired or not found')));
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}, 2000);
|
|
132
|
-
}
|
|
133
|
-
async handleAnswer(sdp) {
|
|
134
|
-
try {
|
|
135
|
-
await this.peer.pc.setRemoteDescription({
|
|
136
|
-
type: 'answer',
|
|
137
|
-
sdp
|
|
138
|
-
});
|
|
139
|
-
// Transition to exchanging ICE
|
|
140
|
-
this.peer.setState(new ExchangingIceState(this.peer, this.offerId, this.options));
|
|
141
|
-
}
|
|
142
|
-
catch (error) {
|
|
143
|
-
this.peer.setState(new FailedState(this.peer, error));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
cleanup() {
|
|
147
|
-
if (this.pollingInterval)
|
|
148
|
-
clearInterval(this.pollingInterval);
|
|
149
|
-
if (this.timeout)
|
|
150
|
-
clearTimeout(this.timeout);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Answering an offer and sending to server
|
|
155
|
-
*/
|
|
156
|
-
class AnsweringState extends PeerState {
|
|
157
|
-
constructor(peer) {
|
|
158
|
-
super(peer);
|
|
159
|
-
}
|
|
160
|
-
get name() { return 'answering'; }
|
|
161
|
-
async answer(offerId, offerSdp, options) {
|
|
162
|
-
try {
|
|
163
|
-
this.peer.role = 'answerer';
|
|
164
|
-
this.peer.offerId = offerId;
|
|
165
|
-
// Set remote description
|
|
166
|
-
await this.peer.pc.setRemoteDescription({
|
|
167
|
-
type: 'offer',
|
|
168
|
-
sdp: offerSdp
|
|
169
|
-
});
|
|
170
|
-
// Create answer
|
|
171
|
-
const answer = await this.peer.pc.createAnswer();
|
|
172
|
-
await this.peer.pc.setLocalDescription(answer);
|
|
173
|
-
// Send answer to server immediately (don't wait for ICE)
|
|
174
|
-
await this.peer.offersApi.answer(offerId, answer.sdp);
|
|
175
|
-
// Enable trickle ICE - send candidates as they arrive
|
|
176
|
-
this.peer.pc.onicecandidate = async (event) => {
|
|
177
|
-
if (event.candidate && offerId) {
|
|
178
|
-
const candidateData = event.candidate.toJSON();
|
|
179
|
-
if (candidateData.candidate && candidateData.candidate !== '') {
|
|
180
|
-
try {
|
|
181
|
-
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
|
|
182
|
-
}
|
|
183
|
-
catch (err) {
|
|
184
|
-
console.error('Error sending ICE candidate:', err);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
// Transition to exchanging ICE
|
|
190
|
-
this.peer.setState(new ExchangingIceState(this.peer, offerId, options));
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
this.peer.setState(new FailedState(this.peer, error));
|
|
194
|
-
throw error;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Exchanging ICE candidates and waiting for connection
|
|
200
|
-
*/
|
|
201
|
-
class ExchangingIceState extends PeerState {
|
|
202
|
-
constructor(peer, offerId, options) {
|
|
203
|
-
super(peer);
|
|
204
|
-
this.offerId = offerId;
|
|
205
|
-
this.options = options;
|
|
206
|
-
this.lastIceTimestamp = 0;
|
|
207
|
-
this.startPolling();
|
|
208
|
-
}
|
|
209
|
-
get name() { return 'exchanging-ice'; }
|
|
210
|
-
startPolling() {
|
|
211
|
-
const connectionTimeout = this.options.timeouts?.iceConnection || 30000;
|
|
212
|
-
this.timeout = setTimeout(() => {
|
|
213
|
-
this.cleanup();
|
|
214
|
-
this.peer.setState(new FailedState(this.peer, new Error('ICE connection timeout')));
|
|
215
|
-
}, connectionTimeout);
|
|
216
|
-
this.pollingInterval = setInterval(async () => {
|
|
217
|
-
try {
|
|
218
|
-
const candidates = await this.peer.offersApi.getIceCandidates(this.offerId, this.lastIceTimestamp);
|
|
219
|
-
for (const cand of candidates) {
|
|
220
|
-
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
|
|
221
|
-
try {
|
|
222
|
-
await this.peer.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
|
|
223
|
-
this.lastIceTimestamp = cand.createdAt;
|
|
224
|
-
}
|
|
225
|
-
catch (err) {
|
|
226
|
-
console.warn('Failed to add ICE candidate:', err);
|
|
227
|
-
this.lastIceTimestamp = cand.createdAt;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
this.lastIceTimestamp = cand.createdAt;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
catch (err) {
|
|
236
|
-
console.error('Error polling for ICE candidates:', err);
|
|
237
|
-
if (err instanceof Error && err.message.includes('not found')) {
|
|
238
|
-
this.cleanup();
|
|
239
|
-
this.peer.setState(new FailedState(this.peer, new Error('Offer expired or not found')));
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}, 1000);
|
|
243
|
-
}
|
|
244
|
-
cleanup() {
|
|
245
|
-
if (this.pollingInterval)
|
|
246
|
-
clearInterval(this.pollingInterval);
|
|
247
|
-
if (this.timeout)
|
|
248
|
-
clearTimeout(this.timeout);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Successfully connected state
|
|
253
|
-
*/
|
|
254
|
-
class ConnectedState extends PeerState {
|
|
255
|
-
get name() { return 'connected'; }
|
|
256
|
-
cleanup() {
|
|
257
|
-
// Keep connection alive, but stop any polling
|
|
258
|
-
// The peer connection will handle disconnects via onconnectionstatechange
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Failed state
|
|
263
|
-
*/
|
|
264
|
-
class FailedState extends PeerState {
|
|
265
|
-
constructor(peer, error) {
|
|
266
|
-
super(peer);
|
|
267
|
-
this.error = error;
|
|
268
|
-
peer.emitEvent('failed', error);
|
|
269
|
-
}
|
|
270
|
-
get name() { return 'failed'; }
|
|
271
|
-
cleanup() {
|
|
272
|
-
// Connection is failed, clean up resources
|
|
273
|
-
this.peer.pc.close();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Closed state
|
|
278
|
-
*/
|
|
279
|
-
class ClosedState extends PeerState {
|
|
280
|
-
get name() { return 'closed'; }
|
|
281
|
-
cleanup() {
|
|
282
|
-
this.peer.pc.close();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
2
|
+
import { IdleState } from './idle-state.js';
|
|
3
|
+
import { ConnectedState } from './connected-state.js';
|
|
4
|
+
import { FailedState } from './failed-state.js';
|
|
5
|
+
import { ClosedState } from './closed-state.js';
|
|
285
6
|
/**
|
|
286
7
|
* High-level WebRTC peer connection manager with state-based lifecycle
|
|
287
8
|
* Handles offer/answer exchange, ICE candidates, timeouts, and error recovery
|
|
@@ -321,7 +42,7 @@ export default class RondevuPeer extends EventEmitter {
|
|
|
321
42
|
* Set up peer connection event handlers
|
|
322
43
|
*/
|
|
323
44
|
setupPeerConnection() {
|
|
324
|
-
this.
|
|
45
|
+
this.connectionStateChangeHandler = () => {
|
|
325
46
|
switch (this.pc.connectionState) {
|
|
326
47
|
case 'connected':
|
|
327
48
|
this.setState(new ConnectedState(this));
|
|
@@ -339,15 +60,19 @@ export default class RondevuPeer extends EventEmitter {
|
|
|
339
60
|
break;
|
|
340
61
|
}
|
|
341
62
|
};
|
|
342
|
-
this.pc.
|
|
63
|
+
this.pc.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
|
|
64
|
+
this.dataChannelHandler = (event) => {
|
|
343
65
|
this.emitEvent('datachannel', event.channel);
|
|
344
66
|
};
|
|
345
|
-
this.pc.
|
|
67
|
+
this.pc.addEventListener('datachannel', this.dataChannelHandler);
|
|
68
|
+
this.trackHandler = (event) => {
|
|
346
69
|
this.emitEvent('track', event);
|
|
347
70
|
};
|
|
348
|
-
this.pc.
|
|
71
|
+
this.pc.addEventListener('track', this.trackHandler);
|
|
72
|
+
this.iceCandidateErrorHandler = (event) => {
|
|
349
73
|
console.error('ICE candidate error:', event);
|
|
350
74
|
};
|
|
75
|
+
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
|
|
351
76
|
}
|
|
352
77
|
/**
|
|
353
78
|
* Set new state and emit state change event
|
|
@@ -385,8 +110,21 @@ export default class RondevuPeer extends EventEmitter {
|
|
|
385
110
|
/**
|
|
386
111
|
* Close the connection and clean up
|
|
387
112
|
*/
|
|
388
|
-
close() {
|
|
389
|
-
|
|
113
|
+
async close() {
|
|
114
|
+
// Remove RTCPeerConnection event listeners
|
|
115
|
+
if (this.connectionStateChangeHandler) {
|
|
116
|
+
this.pc.removeEventListener('connectionstatechange', this.connectionStateChangeHandler);
|
|
117
|
+
}
|
|
118
|
+
if (this.dataChannelHandler) {
|
|
119
|
+
this.pc.removeEventListener('datachannel', this.dataChannelHandler);
|
|
120
|
+
}
|
|
121
|
+
if (this.trackHandler) {
|
|
122
|
+
this.pc.removeEventListener('track', this.trackHandler);
|
|
123
|
+
}
|
|
124
|
+
if (this.iceCandidateErrorHandler) {
|
|
125
|
+
this.pc.removeEventListener('icecandidateerror', this.iceCandidateErrorHandler);
|
|
126
|
+
}
|
|
127
|
+
await this._state.close();
|
|
390
128
|
this.removeAllListeners();
|
|
391
129
|
}
|
|
392
130
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PeerOptions } from './types.js';
|
|
2
|
+
import type RondevuPeer from './index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Base class for peer connection states
|
|
5
|
+
* Implements the State pattern for managing WebRTC connection lifecycle
|
|
6
|
+
*/
|
|
7
|
+
export declare abstract class PeerState {
|
|
8
|
+
protected peer: RondevuPeer;
|
|
9
|
+
protected iceCandidateHandler?: (event: RTCPeerConnectionIceEvent) => void;
|
|
10
|
+
constructor(peer: RondevuPeer);
|
|
11
|
+
abstract get name(): string;
|
|
12
|
+
createOffer(options: PeerOptions): Promise<string>;
|
|
13
|
+
answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void>;
|
|
14
|
+
handleAnswer(sdp: string): Promise<void>;
|
|
15
|
+
handleIceCandidate(candidate: any): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Setup trickle ICE candidate handler
|
|
18
|
+
* Sends local ICE candidates to server as they are discovered
|
|
19
|
+
*/
|
|
20
|
+
protected setupIceCandidateHandler(offerId: string): void;
|
|
21
|
+
cleanup(): void;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for peer connection states
|
|
3
|
+
* Implements the State pattern for managing WebRTC connection lifecycle
|
|
4
|
+
*/
|
|
5
|
+
export class PeerState {
|
|
6
|
+
constructor(peer) {
|
|
7
|
+
this.peer = peer;
|
|
8
|
+
}
|
|
9
|
+
async createOffer(options) {
|
|
10
|
+
throw new Error(`Cannot create offer in ${this.name} state`);
|
|
11
|
+
}
|
|
12
|
+
async answer(offerId, offerSdp, options) {
|
|
13
|
+
throw new Error(`Cannot answer in ${this.name} state`);
|
|
14
|
+
}
|
|
15
|
+
async handleAnswer(sdp) {
|
|
16
|
+
throw new Error(`Cannot handle answer in ${this.name} state`);
|
|
17
|
+
}
|
|
18
|
+
async handleIceCandidate(candidate) {
|
|
19
|
+
// ICE candidates can arrive in multiple states, so default is to add them
|
|
20
|
+
if (this.peer.pc.remoteDescription) {
|
|
21
|
+
await this.peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Setup trickle ICE candidate handler
|
|
26
|
+
* Sends local ICE candidates to server as they are discovered
|
|
27
|
+
*/
|
|
28
|
+
setupIceCandidateHandler(offerId) {
|
|
29
|
+
this.iceCandidateHandler = async (event) => {
|
|
30
|
+
if (event.candidate && offerId) {
|
|
31
|
+
const candidateData = event.candidate.toJSON();
|
|
32
|
+
if (candidateData.candidate && candidateData.candidate !== '') {
|
|
33
|
+
try {
|
|
34
|
+
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
console.error('Error sending ICE candidate:', err);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);
|
|
43
|
+
}
|
|
44
|
+
cleanup() {
|
|
45
|
+
// Clean up ICE candidate handler if it exists
|
|
46
|
+
if (this.iceCandidateHandler) {
|
|
47
|
+
this.peer.pc.removeEventListener('icecandidate', this.iceCandidateHandler);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async close() {
|
|
51
|
+
this.cleanup();
|
|
52
|
+
const { ClosedState } = await import('./closed-state.js');
|
|
53
|
+
this.peer.setState(new ClosedState(this.peer));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout configurations for different connection phases
|
|
3
|
+
*/
|
|
4
|
+
export interface PeerTimeouts {
|
|
5
|
+
/** Timeout for ICE gathering (default: 10000ms) */
|
|
6
|
+
iceGathering?: number;
|
|
7
|
+
/** Timeout for waiting for answer (default: 30000ms) */
|
|
8
|
+
waitingForAnswer?: number;
|
|
9
|
+
/** Timeout for creating answer (default: 10000ms) */
|
|
10
|
+
creatingAnswer?: number;
|
|
11
|
+
/** Timeout for ICE connection (default: 30000ms) */
|
|
12
|
+
iceConnection?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Options for creating a peer connection
|
|
16
|
+
*/
|
|
17
|
+
export interface PeerOptions {
|
|
18
|
+
/** RTCConfiguration for the peer connection */
|
|
19
|
+
rtcConfig?: RTCConfiguration;
|
|
20
|
+
/** Topics to advertise this connection under */
|
|
21
|
+
topics: string[];
|
|
22
|
+
/** How long the offer should live (milliseconds) */
|
|
23
|
+
ttl?: number;
|
|
24
|
+
/** Whether to create a data channel automatically (for offerer) */
|
|
25
|
+
createDataChannel?: boolean;
|
|
26
|
+
/** Label for the automatically created data channel */
|
|
27
|
+
dataChannelLabel?: string;
|
|
28
|
+
/** Timeout configurations */
|
|
29
|
+
timeouts?: PeerTimeouts;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Events emitted by RondevuPeer
|
|
33
|
+
*/
|
|
34
|
+
export interface PeerEvents extends Record<string, (...args: any[]) => void> {
|
|
35
|
+
'state': (state: string) => void;
|
|
36
|
+
'connected': () => void;
|
|
37
|
+
'disconnected': () => void;
|
|
38
|
+
'failed': (error: Error) => void;
|
|
39
|
+
'datachannel': (channel: RTCDataChannel) => void;
|
|
40
|
+
'track': (event: RTCTrackEvent) => void;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
import type { PeerOptions } from './types.js';
|
|
3
|
+
import type RondevuPeer from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Waiting for answer from another peer
|
|
6
|
+
*/
|
|
7
|
+
export declare class WaitingForAnswerState extends PeerState {
|
|
8
|
+
private offerId;
|
|
9
|
+
private options;
|
|
10
|
+
private pollingInterval?;
|
|
11
|
+
private timeout?;
|
|
12
|
+
constructor(peer: RondevuPeer, offerId: string, options: PeerOptions);
|
|
13
|
+
get name(): string;
|
|
14
|
+
private startPolling;
|
|
15
|
+
handleAnswer(sdp: string): Promise<void>;
|
|
16
|
+
cleanup(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { PeerState } from './state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Waiting for answer from another peer
|
|
4
|
+
*/
|
|
5
|
+
export class WaitingForAnswerState extends PeerState {
|
|
6
|
+
constructor(peer, offerId, options) {
|
|
7
|
+
super(peer);
|
|
8
|
+
this.offerId = offerId;
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.startPolling();
|
|
11
|
+
}
|
|
12
|
+
get name() { return 'waiting-for-answer'; }
|
|
13
|
+
startPolling() {
|
|
14
|
+
const answerTimeout = this.options.timeouts?.waitingForAnswer || 30000;
|
|
15
|
+
this.timeout = setTimeout(async () => {
|
|
16
|
+
this.cleanup();
|
|
17
|
+
const { FailedState } = await import('./failed-state.js');
|
|
18
|
+
this.peer.setState(new FailedState(this.peer, new Error('Timeout waiting for answer')));
|
|
19
|
+
}, answerTimeout);
|
|
20
|
+
this.pollingInterval = setInterval(async () => {
|
|
21
|
+
try {
|
|
22
|
+
const answers = await this.peer.offersApi.getAnswers();
|
|
23
|
+
const myAnswer = answers.find((a) => a.offerId === this.offerId);
|
|
24
|
+
if (myAnswer) {
|
|
25
|
+
this.cleanup();
|
|
26
|
+
await this.handleAnswer(myAnswer.sdp);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error('Error polling for answers:', err);
|
|
31
|
+
if (err instanceof Error && err.message.includes('not found')) {
|
|
32
|
+
this.cleanup();
|
|
33
|
+
const { FailedState } = await import('./failed-state.js');
|
|
34
|
+
this.peer.setState(new FailedState(this.peer, new Error('Offer expired or not found')));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, 2000);
|
|
38
|
+
}
|
|
39
|
+
async handleAnswer(sdp) {
|
|
40
|
+
try {
|
|
41
|
+
await this.peer.pc.setRemoteDescription({
|
|
42
|
+
type: 'answer',
|
|
43
|
+
sdp
|
|
44
|
+
});
|
|
45
|
+
// Transition to exchanging ICE
|
|
46
|
+
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
|
|
47
|
+
this.peer.setState(new ExchangingIceState(this.peer, this.offerId, this.options));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const { FailedState } = await import('./failed-state.js');
|
|
51
|
+
this.peer.setState(new FailedState(this.peer, error));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cleanup() {
|
|
55
|
+
if (this.pollingInterval)
|
|
56
|
+
clearInterval(this.pollingInterval);
|
|
57
|
+
if (this.timeout)
|
|
58
|
+
clearTimeout(this.timeout);
|
|
59
|
+
}
|
|
60
|
+
}
|