@xtr-dev/rondevu-client 0.18.10 → 0.21.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/README.md +92 -117
- package/dist/api/batcher.d.ts +83 -0
- package/dist/api/batcher.js +155 -0
- package/dist/api/client.d.ts +198 -0
- package/dist/api/client.js +400 -0
- package/dist/{answerer-connection.d.ts → connections/answerer.d.ts} +25 -8
- package/dist/{answerer-connection.js → connections/answerer.js} +70 -48
- package/dist/{connection.d.ts → connections/base.d.ts} +30 -7
- package/dist/{connection.js → connections/base.js} +65 -14
- package/dist/connections/config.d.ts +51 -0
- package/dist/{connection-config.js → connections/config.js} +20 -0
- package/dist/{connection-events.d.ts → connections/events.d.ts} +6 -6
- package/dist/connections/offerer.d.ts +108 -0
- package/dist/connections/offerer.js +306 -0
- package/dist/core/ice-config.d.ts +35 -0
- package/dist/core/ice-config.js +111 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +22 -0
- package/dist/core/offer-pool.d.ts +113 -0
- package/dist/core/offer-pool.js +281 -0
- package/dist/core/peer.d.ts +155 -0
- package/dist/core/peer.js +252 -0
- package/dist/core/polling-manager.d.ts +71 -0
- package/dist/core/polling-manager.js +122 -0
- package/dist/core/rondevu-errors.d.ts +59 -0
- package/dist/core/rondevu-errors.js +75 -0
- package/dist/core/rondevu-types.d.ts +125 -0
- package/dist/core/rondevu-types.js +6 -0
- package/dist/core/rondevu.d.ts +296 -0
- package/dist/core/rondevu.js +472 -0
- package/dist/crypto/adapter.d.ts +53 -0
- package/dist/crypto/node.d.ts +57 -0
- package/dist/crypto/node.js +149 -0
- package/dist/crypto/web.d.ts +38 -0
- package/dist/crypto/web.js +129 -0
- package/dist/utils/async-lock.d.ts +42 -0
- package/dist/utils/async-lock.js +75 -0
- package/dist/{message-buffer.d.ts → utils/message-buffer.d.ts} +1 -1
- package/dist/{message-buffer.js → utils/message-buffer.js} +4 -4
- package/dist/webrtc/adapter.d.ts +22 -0
- package/dist/webrtc/adapter.js +5 -0
- package/dist/webrtc/browser.d.ts +12 -0
- package/dist/webrtc/browser.js +15 -0
- package/dist/webrtc/node.d.ts +32 -0
- package/dist/webrtc/node.js +32 -0
- package/package.json +20 -9
- package/dist/api.d.ts +0 -146
- package/dist/api.js +0 -279
- package/dist/connection-config.d.ts +0 -21
- package/dist/crypto-adapter.d.ts +0 -37
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -10
- package/dist/node-crypto-adapter.d.ts +0 -35
- package/dist/node-crypto-adapter.js +0 -78
- package/dist/offerer-connection.d.ts +0 -54
- package/dist/offerer-connection.js +0 -177
- package/dist/rondevu-signaler.d.ts +0 -112
- package/dist/rondevu-signaler.js +0 -401
- package/dist/rondevu.d.ts +0 -407
- package/dist/rondevu.js +0 -847
- package/dist/rpc-batcher.d.ts +0 -61
- package/dist/rpc-batcher.js +0 -111
- package/dist/web-crypto-adapter.d.ts +0 -16
- package/dist/web-crypto-adapter.js +0 -52
- /package/dist/{connection-events.js → connections/events.js} +0 -0
- /package/dist/{types.d.ts → core/types.d.ts} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
- /package/dist/{crypto-adapter.js → crypto/adapter.js} +0 -0
- /package/dist/{exponential-backoff.d.ts → utils/exponential-backoff.d.ts} +0 -0
- /package/dist/{exponential-backoff.js → utils/exponential-backoff.js} +0 -0
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Offerer-side WebRTC connection with offer creation and answer processing
|
|
3
|
-
*/
|
|
4
|
-
import { RondevuConnection } from './connection.js';
|
|
5
|
-
import { ConnectionState } from './connection-events.js';
|
|
6
|
-
/**
|
|
7
|
-
* Offerer connection - manages already-created offers and waits for answers
|
|
8
|
-
*/
|
|
9
|
-
export class OffererConnection extends RondevuConnection {
|
|
10
|
-
constructor(options) {
|
|
11
|
-
super(undefined, options.config); // rtcConfig not needed, PC already created
|
|
12
|
-
this.api = options.api;
|
|
13
|
-
this.serviceFqn = options.serviceFqn;
|
|
14
|
-
this.offerId = options.offerId;
|
|
15
|
-
// Use the already-created peer connection and data channel
|
|
16
|
-
this.pc = options.pc;
|
|
17
|
-
this.dc = options.dc || null;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Initialize the connection - setup handlers for already-created offer
|
|
21
|
-
*/
|
|
22
|
-
async initialize() {
|
|
23
|
-
this.debug('Initializing offerer connection');
|
|
24
|
-
if (!this.pc)
|
|
25
|
-
throw new Error('Peer connection not provided');
|
|
26
|
-
// Setup peer connection event handlers
|
|
27
|
-
this.pc.onicecandidate = (event) => this.handleIceCandidate(event);
|
|
28
|
-
this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange();
|
|
29
|
-
this.pc.onconnectionstatechange = () => this.handleConnectionStateChange();
|
|
30
|
-
this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange();
|
|
31
|
-
// Setup data channel handlers if we have one
|
|
32
|
-
if (this.dc) {
|
|
33
|
-
this.setupDataChannelHandlers(this.dc);
|
|
34
|
-
}
|
|
35
|
-
// Start connection timeout
|
|
36
|
-
this.startConnectionTimeout();
|
|
37
|
-
// Transition to signaling state (offer already created and published)
|
|
38
|
-
this.transitionTo(ConnectionState.SIGNALING, 'Offer published, waiting for answer');
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Process an answer from the answerer
|
|
42
|
-
*/
|
|
43
|
-
async processAnswer(sdp, answererId) {
|
|
44
|
-
if (!this.pc) {
|
|
45
|
-
this.debug('Cannot process answer: peer connection not initialized');
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
// Generate SDP fingerprint for deduplication
|
|
49
|
-
const fingerprint = await this.hashSdp(sdp);
|
|
50
|
-
// Check for duplicate answer
|
|
51
|
-
if (this.answerProcessed) {
|
|
52
|
-
if (this.answerSdpFingerprint === fingerprint) {
|
|
53
|
-
this.debug('Duplicate answer detected (same fingerprint), skipping');
|
|
54
|
-
this.emit('answer:duplicate', this.offerId);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
throw new Error('Received different answer after already processing one (protocol violation)');
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
// Validate state
|
|
62
|
-
if (this.state !== ConnectionState.SIGNALING && this.state !== ConnectionState.CHECKING) {
|
|
63
|
-
this.debug(`Cannot process answer in state ${this.state}`);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
// Mark as processed BEFORE setRemoteDescription to prevent race conditions
|
|
67
|
-
this.answerProcessed = true;
|
|
68
|
-
this.answerSdpFingerprint = fingerprint;
|
|
69
|
-
try {
|
|
70
|
-
await this.pc.setRemoteDescription({
|
|
71
|
-
type: 'answer',
|
|
72
|
-
sdp,
|
|
73
|
-
});
|
|
74
|
-
this.debug(`Answer processed successfully from ${answererId}`);
|
|
75
|
-
this.emit('answer:processed', this.offerId, answererId);
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
// Reset flags on error so we can try again
|
|
79
|
-
this.answerProcessed = false;
|
|
80
|
-
this.answerSdpFingerprint = null;
|
|
81
|
-
this.debug('Failed to set remote description:', error);
|
|
82
|
-
throw error;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Generate a hash fingerprint of SDP for deduplication
|
|
87
|
-
*/
|
|
88
|
-
async hashSdp(sdp) {
|
|
89
|
-
// Simple hash using built-in crypto if available
|
|
90
|
-
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
91
|
-
const encoder = new TextEncoder();
|
|
92
|
-
const data = encoder.encode(sdp);
|
|
93
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
94
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
95
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
// Fallback: use simple string hash
|
|
99
|
-
let hash = 0;
|
|
100
|
-
for (let i = 0; i < sdp.length; i++) {
|
|
101
|
-
const char = sdp.charCodeAt(i);
|
|
102
|
-
hash = (hash << 5) - hash + char;
|
|
103
|
-
hash = hash & hash;
|
|
104
|
-
}
|
|
105
|
-
return hash.toString(16);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Handle local ICE candidate generation
|
|
110
|
-
*/
|
|
111
|
-
onLocalIceCandidate(candidate) {
|
|
112
|
-
this.debug('Generated local ICE candidate');
|
|
113
|
-
// Send ICE candidate to server
|
|
114
|
-
this.api
|
|
115
|
-
.addOfferIceCandidates(this.serviceFqn, this.offerId, [
|
|
116
|
-
{
|
|
117
|
-
candidate: candidate.candidate,
|
|
118
|
-
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
119
|
-
sdpMid: candidate.sdpMid,
|
|
120
|
-
},
|
|
121
|
-
])
|
|
122
|
-
.catch((error) => {
|
|
123
|
-
this.debug('Failed to send ICE candidate:', error);
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Poll for remote ICE candidates
|
|
128
|
-
*/
|
|
129
|
-
pollIceCandidates() {
|
|
130
|
-
this.api
|
|
131
|
-
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime)
|
|
132
|
-
.then((result) => {
|
|
133
|
-
if (result.candidates.length > 0) {
|
|
134
|
-
this.debug(`Received ${result.candidates.length} remote ICE candidates`);
|
|
135
|
-
for (const iceCandidate of result.candidates) {
|
|
136
|
-
if (iceCandidate.candidate && this.pc) {
|
|
137
|
-
const candidate = iceCandidate.candidate;
|
|
138
|
-
this.pc
|
|
139
|
-
.addIceCandidate(new RTCIceCandidate(candidate))
|
|
140
|
-
.then(() => {
|
|
141
|
-
this.emit('ice:candidate:remote', new RTCIceCandidate(candidate));
|
|
142
|
-
})
|
|
143
|
-
.catch((error) => {
|
|
144
|
-
this.debug('Failed to add ICE candidate:', error);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
// Update last poll time
|
|
148
|
-
if (iceCandidate.createdAt > this.lastIcePollTime) {
|
|
149
|
-
this.lastIcePollTime = iceCandidate.createdAt;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
.catch((error) => {
|
|
155
|
-
this.debug('Failed to poll ICE candidates:', error);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Attempt to reconnect
|
|
160
|
-
*
|
|
161
|
-
* Note: For offerer connections, reconnection is handled by the Rondevu instance
|
|
162
|
-
* creating a new offer via fillOffers(). This method is a no-op.
|
|
163
|
-
*/
|
|
164
|
-
attemptReconnect() {
|
|
165
|
-
this.debug('Reconnection not applicable for offerer - new offer will be created by Rondevu instance');
|
|
166
|
-
// Offerer reconnection is handled externally by Rondevu.fillOffers()
|
|
167
|
-
// which creates entirely new offers. We don't reconnect the same offer.
|
|
168
|
-
// Just emit failure and let the parent handle it.
|
|
169
|
-
this.emit('reconnect:failed', new Error('Offerer reconnection handled by parent'));
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Get the offer ID
|
|
173
|
-
*/
|
|
174
|
-
getOfferId() {
|
|
175
|
-
return this.offerId;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { Signaler, Binnable } from './types.js';
|
|
2
|
-
import { Rondevu } from './rondevu.js';
|
|
3
|
-
export interface PollingConfig {
|
|
4
|
-
initialInterval?: number;
|
|
5
|
-
maxInterval?: number;
|
|
6
|
-
backoffMultiplier?: number;
|
|
7
|
-
maxRetries?: number;
|
|
8
|
-
jitter?: boolean;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* RondevuSignaler - Handles WebRTC signaling via Rondevu service
|
|
12
|
-
*
|
|
13
|
-
* Manages offer/answer exchange and ICE candidate polling for establishing
|
|
14
|
-
* WebRTC connections through the Rondevu signaling server.
|
|
15
|
-
*
|
|
16
|
-
* Supports configurable polling with exponential backoff and jitter to reduce
|
|
17
|
-
* server load and prevent thundering herd issues.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```typescript
|
|
21
|
-
* const signaler = new RondevuSignaler(
|
|
22
|
-
* rondevuService,
|
|
23
|
-
* 'chat.app@1.0.0',
|
|
24
|
-
* 'peer-username',
|
|
25
|
-
* { initialInterval: 500, maxInterval: 5000, jitter: true }
|
|
26
|
-
* )
|
|
27
|
-
*
|
|
28
|
-
* // For offerer:
|
|
29
|
-
* await signaler.setOffer(offer)
|
|
30
|
-
* signaler.addAnswerListener(answer => {
|
|
31
|
-
* // Handle remote answer
|
|
32
|
-
* })
|
|
33
|
-
*
|
|
34
|
-
* // For answerer:
|
|
35
|
-
* signaler.addOfferListener(offer => {
|
|
36
|
-
* // Handle remote offer
|
|
37
|
-
* })
|
|
38
|
-
* await signaler.setAnswer(answer)
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
|
-
export declare class RondevuSignaler implements Signaler {
|
|
42
|
-
private readonly rondevu;
|
|
43
|
-
private readonly service;
|
|
44
|
-
private readonly host?;
|
|
45
|
-
private offerId;
|
|
46
|
-
private serviceFqn;
|
|
47
|
-
private offerListeners;
|
|
48
|
-
private answerListeners;
|
|
49
|
-
private iceListeners;
|
|
50
|
-
private pollingTimeout;
|
|
51
|
-
private icePollingTimeout;
|
|
52
|
-
private lastPollTimestamp;
|
|
53
|
-
private isPolling;
|
|
54
|
-
private isOfferer;
|
|
55
|
-
private pollingConfig;
|
|
56
|
-
constructor(rondevu: Rondevu, service: string, host?: string | undefined, pollingConfig?: PollingConfig);
|
|
57
|
-
/**
|
|
58
|
-
* Publish an offer as a service
|
|
59
|
-
* Used by the offerer to make their offer available
|
|
60
|
-
*/
|
|
61
|
-
setOffer(offer: RTCSessionDescriptionInit): Promise<void>;
|
|
62
|
-
/**
|
|
63
|
-
* Send an answer to the offerer
|
|
64
|
-
* Used by the answerer to respond to an offer
|
|
65
|
-
*/
|
|
66
|
-
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
|
|
67
|
-
/**
|
|
68
|
-
* Listen for incoming offers
|
|
69
|
-
* Used by the answerer to receive offers from the offerer
|
|
70
|
-
*/
|
|
71
|
-
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable;
|
|
72
|
-
/**
|
|
73
|
-
* Listen for incoming answers
|
|
74
|
-
* Used by the offerer to receive the answer from the answerer
|
|
75
|
-
*/
|
|
76
|
-
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable;
|
|
77
|
-
/**
|
|
78
|
-
* Send an ICE candidate to the remote peer
|
|
79
|
-
*/
|
|
80
|
-
addIceCandidate(candidate: RTCIceCandidate): Promise<void>;
|
|
81
|
-
/**
|
|
82
|
-
* Listen for ICE candidates from the remote peer
|
|
83
|
-
*/
|
|
84
|
-
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable;
|
|
85
|
-
/**
|
|
86
|
-
* Search for an offer from the host
|
|
87
|
-
* Used by the answerer to find the offerer's service
|
|
88
|
-
*/
|
|
89
|
-
private searchForOffer;
|
|
90
|
-
/**
|
|
91
|
-
* Start combined polling for answers and ICE candidates (offerer side)
|
|
92
|
-
* Uses poll() for efficient batch polling
|
|
93
|
-
*/
|
|
94
|
-
private startPolling;
|
|
95
|
-
/**
|
|
96
|
-
* Stop combined polling
|
|
97
|
-
*/
|
|
98
|
-
private stopPolling;
|
|
99
|
-
/**
|
|
100
|
-
* Start polling for ICE candidates (answerer side only)
|
|
101
|
-
* Answerers use the separate endpoint since they don't have offers to poll
|
|
102
|
-
*/
|
|
103
|
-
private startIcePolling;
|
|
104
|
-
/**
|
|
105
|
-
* Stop polling for ICE candidates
|
|
106
|
-
*/
|
|
107
|
-
private stopIcePolling;
|
|
108
|
-
/**
|
|
109
|
-
* Stop all polling and cleanup
|
|
110
|
-
*/
|
|
111
|
-
dispose(): void;
|
|
112
|
-
}
|
package/dist/rondevu-signaler.js
DELETED
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RondevuSignaler - Handles WebRTC signaling via Rondevu service
|
|
3
|
-
*
|
|
4
|
-
* Manages offer/answer exchange and ICE candidate polling for establishing
|
|
5
|
-
* WebRTC connections through the Rondevu signaling server.
|
|
6
|
-
*
|
|
7
|
-
* Supports configurable polling with exponential backoff and jitter to reduce
|
|
8
|
-
* server load and prevent thundering herd issues.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```typescript
|
|
12
|
-
* const signaler = new RondevuSignaler(
|
|
13
|
-
* rondevuService,
|
|
14
|
-
* 'chat.app@1.0.0',
|
|
15
|
-
* 'peer-username',
|
|
16
|
-
* { initialInterval: 500, maxInterval: 5000, jitter: true }
|
|
17
|
-
* )
|
|
18
|
-
*
|
|
19
|
-
* // For offerer:
|
|
20
|
-
* await signaler.setOffer(offer)
|
|
21
|
-
* signaler.addAnswerListener(answer => {
|
|
22
|
-
* // Handle remote answer
|
|
23
|
-
* })
|
|
24
|
-
*
|
|
25
|
-
* // For answerer:
|
|
26
|
-
* signaler.addOfferListener(offer => {
|
|
27
|
-
* // Handle remote offer
|
|
28
|
-
* })
|
|
29
|
-
* await signaler.setAnswer(answer)
|
|
30
|
-
* ```
|
|
31
|
-
*/
|
|
32
|
-
export class RondevuSignaler {
|
|
33
|
-
constructor(rondevu, service, host, pollingConfig) {
|
|
34
|
-
this.rondevu = rondevu;
|
|
35
|
-
this.service = service;
|
|
36
|
-
this.host = host;
|
|
37
|
-
this.offerId = null;
|
|
38
|
-
this.serviceFqn = null;
|
|
39
|
-
this.offerListeners = [];
|
|
40
|
-
this.answerListeners = [];
|
|
41
|
-
this.iceListeners = [];
|
|
42
|
-
this.pollingTimeout = null;
|
|
43
|
-
this.icePollingTimeout = null;
|
|
44
|
-
this.lastPollTimestamp = 0;
|
|
45
|
-
this.isPolling = false;
|
|
46
|
-
this.isOfferer = false;
|
|
47
|
-
this.pollingConfig = {
|
|
48
|
-
initialInterval: pollingConfig?.initialInterval ?? 500,
|
|
49
|
-
maxInterval: pollingConfig?.maxInterval ?? 5000,
|
|
50
|
-
backoffMultiplier: pollingConfig?.backoffMultiplier ?? 1.5,
|
|
51
|
-
maxRetries: pollingConfig?.maxRetries ?? 50,
|
|
52
|
-
jitter: pollingConfig?.jitter ?? true
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Publish an offer as a service
|
|
57
|
-
* Used by the offerer to make their offer available
|
|
58
|
-
*/
|
|
59
|
-
async setOffer(offer) {
|
|
60
|
-
if (!offer.sdp) {
|
|
61
|
-
throw new Error('Offer SDP is required');
|
|
62
|
-
}
|
|
63
|
-
// Publish service with the offer SDP
|
|
64
|
-
const publishedService = await this.rondevu.publishService({
|
|
65
|
-
serviceFqn: this.service,
|
|
66
|
-
offers: [{ sdp: offer.sdp }],
|
|
67
|
-
ttl: 300000, // 5 minutes
|
|
68
|
-
});
|
|
69
|
-
// Get the first offer from the published service
|
|
70
|
-
if (!publishedService.offers || publishedService.offers.length === 0) {
|
|
71
|
-
throw new Error('No offers returned from service publication');
|
|
72
|
-
}
|
|
73
|
-
this.offerId = publishedService.offers[0].offerId;
|
|
74
|
-
this.serviceFqn = publishedService.serviceFqn;
|
|
75
|
-
this.isOfferer = true;
|
|
76
|
-
// Start combined polling for answers and ICE candidates
|
|
77
|
-
this.startPolling();
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Send an answer to the offerer
|
|
81
|
-
* Used by the answerer to respond to an offer
|
|
82
|
-
*/
|
|
83
|
-
async setAnswer(answer) {
|
|
84
|
-
if (!answer.sdp) {
|
|
85
|
-
throw new Error('Answer SDP is required');
|
|
86
|
-
}
|
|
87
|
-
if (!this.serviceFqn || !this.offerId) {
|
|
88
|
-
throw new Error('No service FQN or offer ID available. Must receive offer first.');
|
|
89
|
-
}
|
|
90
|
-
// Send answer to the service
|
|
91
|
-
await this.rondevu.getAPIPublic().answerOffer(this.serviceFqn, this.offerId, answer.sdp);
|
|
92
|
-
this.isOfferer = false;
|
|
93
|
-
// Start polling for ICE candidates (answerer uses separate endpoint)
|
|
94
|
-
this.startIcePolling();
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Listen for incoming offers
|
|
98
|
-
* Used by the answerer to receive offers from the offerer
|
|
99
|
-
*/
|
|
100
|
-
addOfferListener(callback) {
|
|
101
|
-
this.offerListeners.push(callback);
|
|
102
|
-
// If we have a host, start searching for their service
|
|
103
|
-
if (this.host && !this.isPolling) {
|
|
104
|
-
this.searchForOffer();
|
|
105
|
-
}
|
|
106
|
-
// Return cleanup function
|
|
107
|
-
return () => {
|
|
108
|
-
const index = this.offerListeners.indexOf(callback);
|
|
109
|
-
if (index > -1) {
|
|
110
|
-
this.offerListeners.splice(index, 1);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Listen for incoming answers
|
|
116
|
-
* Used by the offerer to receive the answer from the answerer
|
|
117
|
-
*/
|
|
118
|
-
addAnswerListener(callback) {
|
|
119
|
-
this.answerListeners.push(callback);
|
|
120
|
-
// Return cleanup function
|
|
121
|
-
return () => {
|
|
122
|
-
const index = this.answerListeners.indexOf(callback);
|
|
123
|
-
if (index > -1) {
|
|
124
|
-
this.answerListeners.splice(index, 1);
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Send an ICE candidate to the remote peer
|
|
130
|
-
*/
|
|
131
|
-
async addIceCandidate(candidate) {
|
|
132
|
-
if (!this.serviceFqn || !this.offerId) {
|
|
133
|
-
console.warn('Cannot send ICE candidate: no service FQN or offer ID');
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const candidateData = candidate.toJSON();
|
|
137
|
-
// Skip empty candidates
|
|
138
|
-
if (!candidateData.candidate || candidateData.candidate === '') {
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
try {
|
|
142
|
-
await this.rondevu.getAPIPublic().addOfferIceCandidates(this.serviceFqn, this.offerId, [candidateData]);
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
console.error('Failed to send ICE candidate:', err);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Listen for ICE candidates from the remote peer
|
|
150
|
-
*/
|
|
151
|
-
addListener(callback) {
|
|
152
|
-
this.iceListeners.push(callback);
|
|
153
|
-
// Return cleanup function
|
|
154
|
-
return () => {
|
|
155
|
-
const index = this.iceListeners.indexOf(callback);
|
|
156
|
-
if (index > -1) {
|
|
157
|
-
this.iceListeners.splice(index, 1);
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Search for an offer from the host
|
|
163
|
-
* Used by the answerer to find the offerer's service
|
|
164
|
-
*/
|
|
165
|
-
async searchForOffer() {
|
|
166
|
-
if (!this.host) {
|
|
167
|
-
throw new Error('No host specified for offer search');
|
|
168
|
-
}
|
|
169
|
-
this.isPolling = true;
|
|
170
|
-
try {
|
|
171
|
-
// Get service by FQN (service should include @username)
|
|
172
|
-
const serviceFqn = `${this.service}@${this.host}`;
|
|
173
|
-
const serviceData = await this.rondevu.getAPIPublic().getService(serviceFqn);
|
|
174
|
-
if (!serviceData) {
|
|
175
|
-
console.warn(`No service found for ${serviceFqn}`);
|
|
176
|
-
this.isPolling = false;
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
// Store service details
|
|
180
|
-
this.offerId = serviceData.offerId;
|
|
181
|
-
this.serviceFqn = serviceData.serviceFqn;
|
|
182
|
-
// Notify offer listeners
|
|
183
|
-
const offer = {
|
|
184
|
-
type: 'offer',
|
|
185
|
-
sdp: serviceData.sdp,
|
|
186
|
-
};
|
|
187
|
-
this.offerListeners.forEach(listener => {
|
|
188
|
-
try {
|
|
189
|
-
listener(offer);
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
console.error('Offer listener error:', err);
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
catch (err) {
|
|
197
|
-
console.error('Failed to search for offer:', err);
|
|
198
|
-
this.isPolling = false;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Start combined polling for answers and ICE candidates (offerer side)
|
|
203
|
-
* Uses poll() for efficient batch polling
|
|
204
|
-
*/
|
|
205
|
-
startPolling() {
|
|
206
|
-
if (this.pollingTimeout || !this.isOfferer) {
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
let interval = this.pollingConfig.initialInterval;
|
|
210
|
-
let retries = 0;
|
|
211
|
-
let answerReceived = false;
|
|
212
|
-
const poll = async () => {
|
|
213
|
-
try {
|
|
214
|
-
const result = await this.rondevu.poll(this.lastPollTimestamp);
|
|
215
|
-
let foundActivity = false;
|
|
216
|
-
// Process answers
|
|
217
|
-
if (result.answers.length > 0 && !answerReceived) {
|
|
218
|
-
foundActivity = true;
|
|
219
|
-
// Find answer for our offerId
|
|
220
|
-
const answer = result.answers.find(a => a.offerId === this.offerId);
|
|
221
|
-
if (answer && answer.sdp) {
|
|
222
|
-
answerReceived = true;
|
|
223
|
-
const answerDesc = {
|
|
224
|
-
type: 'answer',
|
|
225
|
-
sdp: answer.sdp,
|
|
226
|
-
};
|
|
227
|
-
this.answerListeners.forEach(listener => {
|
|
228
|
-
try {
|
|
229
|
-
listener(answerDesc);
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
console.error('Answer listener error:', err);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
// Process ICE candidates for our offer
|
|
239
|
-
if (this.offerId && result.iceCandidates[this.offerId]) {
|
|
240
|
-
const candidates = result.iceCandidates[this.offerId];
|
|
241
|
-
// Filter for answerer candidates (offerer receives answerer's candidates)
|
|
242
|
-
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
243
|
-
if (answererCandidates.length > 0) {
|
|
244
|
-
foundActivity = true;
|
|
245
|
-
for (const item of answererCandidates) {
|
|
246
|
-
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
|
|
247
|
-
try {
|
|
248
|
-
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
|
249
|
-
this.iceListeners.forEach(listener => {
|
|
250
|
-
try {
|
|
251
|
-
listener(rtcCandidate);
|
|
252
|
-
}
|
|
253
|
-
catch (err) {
|
|
254
|
-
console.error('ICE listener error:', err);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
console.warn('Failed to process ICE candidate:', err);
|
|
261
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
// Adjust interval based on activity
|
|
268
|
-
if (foundActivity) {
|
|
269
|
-
interval = this.pollingConfig.initialInterval;
|
|
270
|
-
retries = 0;
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
retries++;
|
|
274
|
-
if (retries > this.pollingConfig.maxRetries) {
|
|
275
|
-
console.warn('Max retries reached for polling');
|
|
276
|
-
this.stopPolling();
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
|
|
280
|
-
}
|
|
281
|
-
// Add jitter to prevent thundering herd
|
|
282
|
-
const finalInterval = this.pollingConfig.jitter
|
|
283
|
-
? interval + Math.random() * 100
|
|
284
|
-
: interval;
|
|
285
|
-
this.pollingTimeout = setTimeout(poll, finalInterval);
|
|
286
|
-
}
|
|
287
|
-
catch (err) {
|
|
288
|
-
console.error('Error polling offers:', err);
|
|
289
|
-
// Retry with backoff
|
|
290
|
-
const finalInterval = this.pollingConfig.jitter
|
|
291
|
-
? interval + Math.random() * 100
|
|
292
|
-
: interval;
|
|
293
|
-
this.pollingTimeout = setTimeout(poll, finalInterval);
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
poll(); // Start immediately
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Stop combined polling
|
|
300
|
-
*/
|
|
301
|
-
stopPolling() {
|
|
302
|
-
if (this.pollingTimeout) {
|
|
303
|
-
clearTimeout(this.pollingTimeout);
|
|
304
|
-
this.pollingTimeout = null;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Start polling for ICE candidates (answerer side only)
|
|
309
|
-
* Answerers use the separate endpoint since they don't have offers to poll
|
|
310
|
-
*/
|
|
311
|
-
startIcePolling() {
|
|
312
|
-
if (this.icePollingTimeout || !this.serviceFqn || !this.offerId || this.isOfferer) {
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
let interval = this.pollingConfig.initialInterval;
|
|
316
|
-
const poll = async () => {
|
|
317
|
-
if (!this.serviceFqn || !this.offerId) {
|
|
318
|
-
this.stopIcePolling();
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
try {
|
|
322
|
-
const result = await this.rondevu
|
|
323
|
-
.getAPIPublic()
|
|
324
|
-
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastPollTimestamp);
|
|
325
|
-
let foundCandidates = false;
|
|
326
|
-
for (const item of result.candidates) {
|
|
327
|
-
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
|
|
328
|
-
foundCandidates = true;
|
|
329
|
-
try {
|
|
330
|
-
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
|
331
|
-
this.iceListeners.forEach(listener => {
|
|
332
|
-
try {
|
|
333
|
-
listener(rtcCandidate);
|
|
334
|
-
}
|
|
335
|
-
catch (err) {
|
|
336
|
-
console.error('ICE listener error:', err);
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
this.lastPollTimestamp = item.createdAt;
|
|
340
|
-
}
|
|
341
|
-
catch (err) {
|
|
342
|
-
console.warn('Failed to process ICE candidate:', err);
|
|
343
|
-
this.lastPollTimestamp = item.createdAt;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
this.lastPollTimestamp = item.createdAt;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
// If candidates found, reset interval to initial value
|
|
351
|
-
// Otherwise, increase interval with backoff
|
|
352
|
-
if (foundCandidates) {
|
|
353
|
-
interval = this.pollingConfig.initialInterval;
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
|
|
357
|
-
}
|
|
358
|
-
// Add jitter
|
|
359
|
-
const finalInterval = this.pollingConfig.jitter
|
|
360
|
-
? interval + Math.random() * 100
|
|
361
|
-
: interval;
|
|
362
|
-
this.icePollingTimeout = setTimeout(poll, finalInterval);
|
|
363
|
-
}
|
|
364
|
-
catch (err) {
|
|
365
|
-
// 404/410 means offer expired, stop polling
|
|
366
|
-
if (err instanceof Error && (err.message?.includes('404') || err.message?.includes('410'))) {
|
|
367
|
-
console.warn('Offer not found or expired, stopping ICE polling');
|
|
368
|
-
this.stopIcePolling();
|
|
369
|
-
}
|
|
370
|
-
else if (err instanceof Error && !err.message?.includes('404')) {
|
|
371
|
-
console.error('Error polling for ICE candidates:', err);
|
|
372
|
-
// Continue polling despite errors
|
|
373
|
-
const finalInterval = this.pollingConfig.jitter
|
|
374
|
-
? interval + Math.random() * 100
|
|
375
|
-
: interval;
|
|
376
|
-
this.icePollingTimeout = setTimeout(poll, finalInterval);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
poll(); // Start immediately
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Stop polling for ICE candidates
|
|
384
|
-
*/
|
|
385
|
-
stopIcePolling() {
|
|
386
|
-
if (this.icePollingTimeout) {
|
|
387
|
-
clearTimeout(this.icePollingTimeout);
|
|
388
|
-
this.icePollingTimeout = null;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* Stop all polling and cleanup
|
|
393
|
-
*/
|
|
394
|
-
dispose() {
|
|
395
|
-
this.stopPolling();
|
|
396
|
-
this.stopIcePolling();
|
|
397
|
-
this.offerListeners = [];
|
|
398
|
-
this.answerListeners = [];
|
|
399
|
-
this.iceListeners = [];
|
|
400
|
-
}
|
|
401
|
-
}
|