@xtr-dev/rondevu-client 0.18.6 → 0.18.8
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/answerer-connection.d.ts +44 -0
- package/dist/answerer-connection.js +145 -0
- package/dist/connection-config.d.ts +21 -0
- package/dist/connection-config.js +30 -0
- package/dist/connection-events.d.ts +78 -0
- package/dist/connection-events.js +16 -0
- package/dist/connection.d.ts +148 -0
- package/dist/connection.js +481 -0
- package/dist/exponential-backoff.d.ts +30 -0
- package/dist/exponential-backoff.js +46 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/message-buffer.d.ts +55 -0
- package/dist/message-buffer.js +106 -0
- package/dist/offerer-connection.d.ts +50 -0
- package/dist/offerer-connection.js +193 -0
- package/dist/rondevu.d.ts +77 -64
- package/dist/rondevu.js +180 -298
- package/package.json +1 -1
package/dist/rondevu.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { RondevuAPI } from './api.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
|
+
import { OffererConnection } from './offerer-connection.js';
|
|
4
|
+
import { AnswererConnection } from './answerer-connection.js';
|
|
3
5
|
// ICE server presets
|
|
4
6
|
export const ICE_SERVER_PRESETS = {
|
|
5
7
|
'ipv4-turn': [
|
|
@@ -86,14 +88,13 @@ export class ConnectionError extends RondevuError {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
/**
|
|
89
|
-
* Rondevu - Complete WebRTC signaling client
|
|
91
|
+
* Rondevu - Complete WebRTC signaling client with durable connections
|
|
90
92
|
*
|
|
91
|
-
*
|
|
92
|
-
* -
|
|
93
|
-
* -
|
|
94
|
-
* -
|
|
95
|
-
* -
|
|
96
|
-
* - Keypair management
|
|
93
|
+
* v1.0.0 introduces breaking changes:
|
|
94
|
+
* - connectToService() now returns AnswererConnection instead of ConnectionContext
|
|
95
|
+
* - Automatic reconnection and message buffering built-in
|
|
96
|
+
* - Connection objects expose .send() method instead of raw DataChannel
|
|
97
|
+
* - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
|
|
97
98
|
*
|
|
98
99
|
* @example
|
|
99
100
|
* ```typescript
|
|
@@ -104,39 +105,39 @@ export class ConnectionError extends RondevuError {
|
|
|
104
105
|
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
|
105
106
|
* })
|
|
106
107
|
*
|
|
107
|
-
* // Or use custom ICE servers
|
|
108
|
-
* const rondevu2 = await Rondevu.connect({
|
|
109
|
-
* apiUrl: 'https://signal.example.com',
|
|
110
|
-
* username: 'bob',
|
|
111
|
-
* iceServers: [
|
|
112
|
-
* { urls: 'stun:stun.l.google.com:19302' },
|
|
113
|
-
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
|
|
114
|
-
* ]
|
|
115
|
-
* })
|
|
116
|
-
*
|
|
117
108
|
* // Publish a service with automatic offer management
|
|
118
109
|
* await rondevu.publishService({
|
|
119
110
|
* service: 'chat:2.0.0',
|
|
120
|
-
* maxOffers: 5
|
|
121
|
-
* offerFactory: async (pc) => {
|
|
122
|
-
* // pc is created by Rondevu with ICE handlers already attached
|
|
123
|
-
* const dc = pc.createDataChannel('chat')
|
|
124
|
-
* const offer = await pc.createOffer()
|
|
125
|
-
* await pc.setLocalDescription(offer)
|
|
126
|
-
* return { dc, offer }
|
|
127
|
-
* }
|
|
111
|
+
* maxOffers: 5 // Maintain up to 5 concurrent offers
|
|
128
112
|
* })
|
|
129
113
|
*
|
|
130
114
|
* // Start accepting connections (auto-fills offers and polls)
|
|
131
115
|
* await rondevu.startFilling()
|
|
132
116
|
*
|
|
133
|
-
* //
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
117
|
+
* // Listen for connections (v1.0.0 API)
|
|
118
|
+
* rondevu.on('connection:opened', (offerId, connection) => {
|
|
119
|
+
* connection.on('connected', () => console.log('Connected!'))
|
|
120
|
+
* connection.on('message', (data) => console.log('Received:', data))
|
|
121
|
+
* connection.send('Hello!')
|
|
122
|
+
* })
|
|
137
123
|
*
|
|
138
|
-
* //
|
|
139
|
-
* rondevu.
|
|
124
|
+
* // Connect to a service (v1.0.0 - returns AnswererConnection)
|
|
125
|
+
* const connection = await rondevu.connectToService({
|
|
126
|
+
* serviceFqn: 'chat:2.0.0@bob'
|
|
127
|
+
* })
|
|
128
|
+
*
|
|
129
|
+
* connection.on('connected', () => {
|
|
130
|
+
* console.log('Connected!')
|
|
131
|
+
* connection.send('Hello!')
|
|
132
|
+
* })
|
|
133
|
+
*
|
|
134
|
+
* connection.on('message', (data) => {
|
|
135
|
+
* console.log('Received:', data)
|
|
136
|
+
* })
|
|
137
|
+
*
|
|
138
|
+
* connection.on('reconnecting', (attempt) => {
|
|
139
|
+
* console.log(`Reconnecting, attempt ${attempt}`)
|
|
140
|
+
* })
|
|
140
141
|
* ```
|
|
141
142
|
*/
|
|
142
143
|
export class Rondevu extends EventEmitter {
|
|
@@ -148,12 +149,12 @@ export class Rondevu extends EventEmitter {
|
|
|
148
149
|
this.maxOffers = 0;
|
|
149
150
|
this.offerFactory = null;
|
|
150
151
|
this.ttl = Rondevu.DEFAULT_TTL_MS;
|
|
151
|
-
this.
|
|
152
|
+
this.activeConnections = new Map();
|
|
152
153
|
// Polling
|
|
153
154
|
this.filling = false;
|
|
155
|
+
this.fillingSemaphore = false; // Semaphore to prevent concurrent fillOffers calls
|
|
154
156
|
this.pollingInterval = null;
|
|
155
157
|
this.lastPollTimestamp = 0;
|
|
156
|
-
this.isPolling = false; // Guard against concurrent poll execution
|
|
157
158
|
this.apiUrl = apiUrl;
|
|
158
159
|
this.username = username;
|
|
159
160
|
this.keypair = keypair;
|
|
@@ -284,49 +285,27 @@ export class Rondevu extends EventEmitter {
|
|
|
284
285
|
* ```typescript
|
|
285
286
|
* await rondevu.publishService({
|
|
286
287
|
* service: 'chat:2.0.0',
|
|
287
|
-
* maxOffers: 5
|
|
288
|
+
* maxOffers: 5,
|
|
289
|
+
* connectionConfig: {
|
|
290
|
+
* reconnectEnabled: true,
|
|
291
|
+
* bufferEnabled: true
|
|
292
|
+
* }
|
|
288
293
|
* })
|
|
289
294
|
* await rondevu.startFilling()
|
|
290
295
|
* ```
|
|
291
296
|
*/
|
|
292
297
|
async publishService(options) {
|
|
293
|
-
const { service, maxOffers, offerFactory, ttl } = options;
|
|
298
|
+
const { service, maxOffers, offerFactory, ttl, connectionConfig } = options;
|
|
294
299
|
this.currentService = service;
|
|
295
300
|
this.maxOffers = maxOffers;
|
|
296
301
|
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
|
|
297
302
|
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
|
|
303
|
+
this.connectionConfig = connectionConfig;
|
|
298
304
|
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
|
|
299
305
|
this.usernameClaimed = true;
|
|
300
306
|
}
|
|
301
307
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* Note: This is used by connectToService() where the offerId is already known.
|
|
305
|
-
* For createOffer(), we use inline ICE handling with early candidate queuing
|
|
306
|
-
* since the offerId isn't available until after the factory completes.
|
|
307
|
-
*/
|
|
308
|
-
setupIceCandidateHandler(pc, serviceFqn, offerId) {
|
|
309
|
-
pc.onicecandidate = async (event) => {
|
|
310
|
-
if (event.candidate) {
|
|
311
|
-
try {
|
|
312
|
-
// Handle both browser and Node.js (wrtc) environments
|
|
313
|
-
// Browser: candidate.toJSON() exists
|
|
314
|
-
// Node.js wrtc: candidate is already a plain object
|
|
315
|
-
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
316
|
-
? event.candidate.toJSON()
|
|
317
|
-
: event.candidate;
|
|
318
|
-
// Emit local ICE candidate event
|
|
319
|
-
this.emit('ice:candidate:local', offerId, candidateData);
|
|
320
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
321
|
-
}
|
|
322
|
-
catch (err) {
|
|
323
|
-
console.error('[Rondevu] Failed to send ICE candidate:', err);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Create a single offer and publish it to the server
|
|
308
|
+
* Create a single offer and publish it to the server using OffererConnection
|
|
330
309
|
*/
|
|
331
310
|
async createOffer() {
|
|
332
311
|
if (!this.currentService || !this.offerFactory) {
|
|
@@ -338,42 +317,9 @@ export class Rondevu extends EventEmitter {
|
|
|
338
317
|
// Auto-append username to service
|
|
339
318
|
const serviceFqn = `${this.currentService}@${this.username}`;
|
|
340
319
|
this.debug('Creating new offer...');
|
|
341
|
-
// 1. Create
|
|
320
|
+
// 1. Create RTCPeerConnection using factory (for now, keep compatibility)
|
|
342
321
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
343
|
-
// 2.
|
|
344
|
-
// This ensures we capture all candidates, even those generated immediately
|
|
345
|
-
// when setLocalDescription() is called in the factory
|
|
346
|
-
const earlyIceCandidates = [];
|
|
347
|
-
let offerId;
|
|
348
|
-
pc.onicecandidate = async (event) => {
|
|
349
|
-
if (event.candidate) {
|
|
350
|
-
// Handle both browser and Node.js (wrtc) environments
|
|
351
|
-
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
352
|
-
? event.candidate.toJSON()
|
|
353
|
-
: event.candidate;
|
|
354
|
-
// Emit local ICE candidate event
|
|
355
|
-
if (offerId) {
|
|
356
|
-
this.emit('ice:candidate:local', offerId, candidateData);
|
|
357
|
-
}
|
|
358
|
-
if (offerId) {
|
|
359
|
-
// We have the offerId, send directly
|
|
360
|
-
try {
|
|
361
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
console.error('[Rondevu] Failed to send ICE candidate:', err);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
else {
|
|
368
|
-
// Queue for later - we don't have the offerId yet
|
|
369
|
-
this.debug('Queuing early ICE candidate');
|
|
370
|
-
earlyIceCandidates.push(candidateData);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
// 3. Call the factory with the pc - factory creates data channel and offer
|
|
375
|
-
// When factory calls setLocalDescription(), ICE gathering starts and
|
|
376
|
-
// candidates are captured by the handler we set up above
|
|
322
|
+
// 2. Call the factory to create offer
|
|
377
323
|
let dc;
|
|
378
324
|
let offer;
|
|
379
325
|
try {
|
|
@@ -382,11 +328,10 @@ export class Rondevu extends EventEmitter {
|
|
|
382
328
|
offer = factoryResult.offer;
|
|
383
329
|
}
|
|
384
330
|
catch (err) {
|
|
385
|
-
// Clean up the connection if factory fails
|
|
386
331
|
pc.close();
|
|
387
332
|
throw err;
|
|
388
333
|
}
|
|
389
|
-
//
|
|
334
|
+
// 3. Publish to server to get offerId
|
|
390
335
|
const result = await this.api.publishService({
|
|
391
336
|
serviceFqn,
|
|
392
337
|
offers: [{ sdp: offer.sdp }],
|
|
@@ -394,62 +339,68 @@ export class Rondevu extends EventEmitter {
|
|
|
394
339
|
signature: '',
|
|
395
340
|
message: '',
|
|
396
341
|
});
|
|
397
|
-
offerId = result.offers[0].offerId;
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
342
|
+
const offerId = result.offers[0].offerId;
|
|
343
|
+
// 4. Create OffererConnection instance
|
|
344
|
+
const connection = new OffererConnection({
|
|
345
|
+
api: this.api,
|
|
401
346
|
serviceFqn,
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
347
|
+
offerId,
|
|
348
|
+
rtcConfig,
|
|
349
|
+
config: {
|
|
350
|
+
...this.connectionConfig,
|
|
351
|
+
debug: this.debugEnabled,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
// Setup connection event handlers
|
|
355
|
+
connection.on('connected', () => {
|
|
356
|
+
this.debug(`Connection established for offer ${offerId}`);
|
|
357
|
+
this.emit('connection:opened', offerId, connection);
|
|
358
|
+
});
|
|
359
|
+
connection.on('failed', (error) => {
|
|
360
|
+
this.debug(`Connection failed for offer ${offerId}:`, error);
|
|
361
|
+
this.activeConnections.delete(offerId);
|
|
362
|
+
this.fillOffers(); // Replace failed offer
|
|
406
363
|
});
|
|
364
|
+
connection.on('closed', () => {
|
|
365
|
+
this.debug(`Connection closed for offer ${offerId}`);
|
|
366
|
+
this.activeConnections.delete(offerId);
|
|
367
|
+
this.fillOffers(); // Replace closed offer
|
|
368
|
+
});
|
|
369
|
+
// Store active connection
|
|
370
|
+
this.activeConnections.set(offerId, connection);
|
|
371
|
+
// Initialize the connection
|
|
372
|
+
await connection.initialize();
|
|
407
373
|
this.debug(`Offer created: ${offerId}`);
|
|
408
374
|
this.emit('offer:created', offerId, serviceFqn);
|
|
409
|
-
// Set up data channel open handler (offerer side)
|
|
410
|
-
if (dc) {
|
|
411
|
-
dc.onopen = () => {
|
|
412
|
-
this.debug(`Data channel opened for offer ${offerId}`);
|
|
413
|
-
this.emit('connection:opened', offerId, dc);
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
// 6. Send any queued early ICE candidates
|
|
417
|
-
if (earlyIceCandidates.length > 0) {
|
|
418
|
-
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
|
|
419
|
-
try {
|
|
420
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates);
|
|
421
|
-
}
|
|
422
|
-
catch (err) {
|
|
423
|
-
console.error('[Rondevu] Failed to send early ICE candidates:', err);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// 7. Monitor connection state
|
|
427
|
-
pc.onconnectionstatechange = () => {
|
|
428
|
-
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
|
|
429
|
-
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
430
|
-
this.emit('connection:closed', offerId);
|
|
431
|
-
this.activeOffers.delete(offerId);
|
|
432
|
-
this.fillOffers(); // Try to replace failed offer
|
|
433
|
-
}
|
|
434
|
-
};
|
|
435
375
|
}
|
|
436
376
|
/**
|
|
437
|
-
* Fill offers to reach maxOffers count
|
|
377
|
+
* Fill offers to reach maxOffers count with semaphore protection
|
|
438
378
|
*/
|
|
439
379
|
async fillOffers() {
|
|
440
380
|
if (!this.filling || !this.currentService)
|
|
441
381
|
return;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
382
|
+
// Semaphore to prevent concurrent fills
|
|
383
|
+
if (this.fillingSemaphore) {
|
|
384
|
+
this.debug('fillOffers already in progress, skipping');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.fillingSemaphore = true;
|
|
388
|
+
try {
|
|
389
|
+
const currentCount = this.activeConnections.size;
|
|
390
|
+
const needed = this.maxOffers - currentCount;
|
|
391
|
+
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
|
|
392
|
+
for (let i = 0; i < needed; i++) {
|
|
393
|
+
try {
|
|
394
|
+
await this.createOffer();
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
console.error('[Rondevu] Failed to create offer:', err);
|
|
398
|
+
}
|
|
451
399
|
}
|
|
452
400
|
}
|
|
401
|
+
finally {
|
|
402
|
+
this.fillingSemaphore = false;
|
|
403
|
+
}
|
|
453
404
|
}
|
|
454
405
|
/**
|
|
455
406
|
* Poll for answers and ICE candidates (internal use for automatic offer management)
|
|
@@ -457,54 +408,20 @@ export class Rondevu extends EventEmitter {
|
|
|
457
408
|
async pollInternal() {
|
|
458
409
|
if (!this.filling)
|
|
459
410
|
return;
|
|
460
|
-
// Prevent concurrent poll execution to avoid duplicate answer processing
|
|
461
|
-
if (this.isPolling) {
|
|
462
|
-
this.debug('Poll already in progress, skipping');
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
this.isPolling = true;
|
|
466
411
|
try {
|
|
467
412
|
const result = await this.api.poll(this.lastPollTimestamp);
|
|
468
|
-
//
|
|
469
|
-
if (result.answers.length > 0) {
|
|
470
|
-
const maxAnswerTimestamp = Math.max(...result.answers.map(a => a.answeredAt));
|
|
471
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, maxAnswerTimestamp);
|
|
472
|
-
}
|
|
473
|
-
// Process answers
|
|
413
|
+
// Process answers - delegate to OffererConnections
|
|
474
414
|
for (const answer of result.answers) {
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
477
|
-
this.debug(`Received answer for offer ${answer.offerId}`);
|
|
478
|
-
// Mark as answered BEFORE setRemoteDescription to prevent race condition
|
|
479
|
-
activeOffer.answered = true;
|
|
415
|
+
const connection = this.activeConnections.get(answer.offerId);
|
|
416
|
+
if (connection) {
|
|
480
417
|
try {
|
|
481
|
-
await
|
|
482
|
-
|
|
483
|
-
sdp: answer.sdp
|
|
484
|
-
});
|
|
485
|
-
this.emit('offer:answered', answer.offerId, answer.answererId);
|
|
418
|
+
await connection.processAnswer(answer.sdp, answer.answererId);
|
|
419
|
+
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
|
|
486
420
|
// Create replacement offer
|
|
487
421
|
this.fillOffers();
|
|
488
422
|
}
|
|
489
423
|
catch (err) {
|
|
490
|
-
|
|
491
|
-
activeOffer.answered = false;
|
|
492
|
-
this.debug(`Failed to set remote description for offer ${answer.offerId}:`, err);
|
|
493
|
-
// Don't throw - continue processing other answers
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
// Process ICE candidates
|
|
498
|
-
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
|
|
499
|
-
const activeOffer = this.activeOffers.get(offerId);
|
|
500
|
-
if (activeOffer) {
|
|
501
|
-
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
502
|
-
for (const item of answererCandidates) {
|
|
503
|
-
if (item.candidate) {
|
|
504
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
505
|
-
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
506
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
507
|
-
}
|
|
424
|
+
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err);
|
|
508
425
|
}
|
|
509
426
|
}
|
|
510
427
|
}
|
|
@@ -512,9 +429,6 @@ export class Rondevu extends EventEmitter {
|
|
|
512
429
|
catch (err) {
|
|
513
430
|
console.error('[Rondevu] Polling error:', err);
|
|
514
431
|
}
|
|
515
|
-
finally {
|
|
516
|
-
this.isPolling = false;
|
|
517
|
-
}
|
|
518
432
|
}
|
|
519
433
|
/**
|
|
520
434
|
* Start filling offers and polling for answers/ICE
|
|
@@ -544,35 +458,34 @@ export class Rondevu extends EventEmitter {
|
|
|
544
458
|
stopFilling() {
|
|
545
459
|
this.debug('Stopping offer filling and polling');
|
|
546
460
|
this.filling = false;
|
|
547
|
-
this.
|
|
461
|
+
this.fillingSemaphore = false;
|
|
548
462
|
// Stop polling
|
|
549
463
|
if (this.pollingInterval) {
|
|
550
464
|
clearInterval(this.pollingInterval);
|
|
551
465
|
this.pollingInterval = null;
|
|
552
466
|
}
|
|
553
467
|
// Close all active connections
|
|
554
|
-
for (const [offerId,
|
|
555
|
-
this.debug(`Closing
|
|
556
|
-
|
|
557
|
-
offer.pc.close();
|
|
468
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
469
|
+
this.debug(`Closing connection ${offerId}`);
|
|
470
|
+
connection.close();
|
|
558
471
|
}
|
|
559
|
-
this.
|
|
472
|
+
this.activeConnections.clear();
|
|
560
473
|
}
|
|
561
474
|
/**
|
|
562
475
|
* Get the count of active offers
|
|
563
476
|
* @returns Number of active offers
|
|
564
477
|
*/
|
|
565
478
|
getOfferCount() {
|
|
566
|
-
return this.
|
|
479
|
+
return this.activeConnections.size;
|
|
567
480
|
}
|
|
568
481
|
/**
|
|
569
482
|
* Check if an offer is currently connected
|
|
570
483
|
* @param offerId - The offer ID to check
|
|
571
|
-
* @returns True if the offer exists and
|
|
484
|
+
* @returns True if the offer exists and is connected
|
|
572
485
|
*/
|
|
573
486
|
isConnected(offerId) {
|
|
574
|
-
const
|
|
575
|
-
return
|
|
487
|
+
const connection = this.activeConnections.get(offerId);
|
|
488
|
+
return connection ? connection.getState() === 'connected' : false;
|
|
576
489
|
}
|
|
577
490
|
/**
|
|
578
491
|
* Disconnect all active offers
|
|
@@ -580,12 +493,11 @@ export class Rondevu extends EventEmitter {
|
|
|
580
493
|
*/
|
|
581
494
|
async disconnectAll() {
|
|
582
495
|
this.debug('Disconnecting all offers');
|
|
583
|
-
for (const [offerId,
|
|
584
|
-
this.debug(`Closing
|
|
585
|
-
|
|
586
|
-
offer.pc.close();
|
|
496
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
497
|
+
this.debug(`Closing connection ${offerId}`);
|
|
498
|
+
connection.close();
|
|
587
499
|
}
|
|
588
|
-
this.
|
|
500
|
+
this.activeConnections.clear();
|
|
589
501
|
}
|
|
590
502
|
/**
|
|
591
503
|
* Get the current service status
|
|
@@ -594,7 +506,7 @@ export class Rondevu extends EventEmitter {
|
|
|
594
506
|
getServiceStatus() {
|
|
595
507
|
return {
|
|
596
508
|
active: this.currentService !== null,
|
|
597
|
-
offerCount: this.
|
|
509
|
+
offerCount: this.activeConnections.size,
|
|
598
510
|
maxOffers: this.maxOffers,
|
|
599
511
|
filling: this.filling
|
|
600
512
|
};
|
|
@@ -622,54 +534,43 @@ export class Rondevu extends EventEmitter {
|
|
|
622
534
|
}
|
|
623
535
|
}
|
|
624
536
|
/**
|
|
625
|
-
*
|
|
626
|
-
* Returns
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
let lastIceTimestamp = 0;
|
|
630
|
-
return setInterval(async () => {
|
|
631
|
-
try {
|
|
632
|
-
const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
|
|
633
|
-
for (const item of result.candidates) {
|
|
634
|
-
if (item.candidate) {
|
|
635
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
636
|
-
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
637
|
-
lastIceTimestamp = item.createdAt;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
catch (err) {
|
|
642
|
-
console.error('[Rondevu] Failed to poll ICE candidates:', err);
|
|
643
|
-
}
|
|
644
|
-
}, Rondevu.POLLING_INTERVAL_MS);
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Automatically connect to a service (answerer side)
|
|
648
|
-
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
|
|
537
|
+
* Connect to a service (answerer side) - v1.0.0 API
|
|
538
|
+
* Returns an AnswererConnection with automatic reconnection and buffering
|
|
539
|
+
*
|
|
540
|
+
* BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
|
|
649
541
|
*
|
|
650
542
|
* @example
|
|
651
543
|
* ```typescript
|
|
652
544
|
* // Connect to specific user
|
|
653
545
|
* const connection = await rondevu.connectToService({
|
|
654
546
|
* serviceFqn: 'chat:2.0.0@alice',
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
* dc.addEventListener('open', () => dc.send('Hello!'))
|
|
547
|
+
* connectionConfig: {
|
|
548
|
+
* reconnectEnabled: true,
|
|
549
|
+
* bufferEnabled: true
|
|
659
550
|
* }
|
|
660
551
|
* })
|
|
661
552
|
*
|
|
553
|
+
* connection.on('connected', () => {
|
|
554
|
+
* console.log('Connected!')
|
|
555
|
+
* connection.send('Hello!')
|
|
556
|
+
* })
|
|
557
|
+
*
|
|
558
|
+
* connection.on('message', (data) => {
|
|
559
|
+
* console.log('Received:', data)
|
|
560
|
+
* })
|
|
561
|
+
*
|
|
562
|
+
* connection.on('reconnecting', (attempt) => {
|
|
563
|
+
* console.log(`Reconnecting, attempt ${attempt}`)
|
|
564
|
+
* })
|
|
565
|
+
*
|
|
662
566
|
* // Discover random service
|
|
663
567
|
* const connection = await rondevu.connectToService({
|
|
664
|
-
* service: 'chat:2.0.0'
|
|
665
|
-
* onConnection: ({ dc, peerUsername }) => {
|
|
666
|
-
* console.log('Connected to', peerUsername)
|
|
667
|
-
* }
|
|
568
|
+
* service: 'chat:2.0.0'
|
|
668
569
|
* })
|
|
669
570
|
* ```
|
|
670
571
|
*/
|
|
671
572
|
async connectToService(options) {
|
|
672
|
-
const {
|
|
573
|
+
const { rtcConfig, connectionConfig } = options;
|
|
673
574
|
// Validate inputs
|
|
674
575
|
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
675
576
|
throw new Error('serviceFqn cannot be empty');
|
|
@@ -683,73 +584,28 @@ export class Rondevu extends EventEmitter {
|
|
|
683
584
|
// Determine the full service FQN
|
|
684
585
|
const fqn = await this.resolveServiceFqn(options);
|
|
685
586
|
this.debug(`Connecting to service: ${fqn}`);
|
|
686
|
-
//
|
|
587
|
+
// Get service offer
|
|
687
588
|
const serviceData = await this.api.getService(fqn);
|
|
688
589
|
this.debug(`Found service from @${serviceData.username}`);
|
|
689
|
-
//
|
|
590
|
+
// Create RTCConfiguration
|
|
690
591
|
const rtcConfiguration = rtcConfig || {
|
|
691
592
|
iceServers: this.iceServers
|
|
692
593
|
};
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const dataChannelPromise = new Promise((resolve) => {
|
|
697
|
-
pc.ondatachannel = (event) => {
|
|
698
|
-
this.debug('Data channel received from offerer');
|
|
699
|
-
dc = event.channel;
|
|
700
|
-
this.emit('connection:opened', serviceData.offerId, dc);
|
|
701
|
-
resolve(dc);
|
|
702
|
-
};
|
|
703
|
-
});
|
|
704
|
-
// 4. Set up ICE candidate exchange
|
|
705
|
-
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
706
|
-
// 5. Poll for remote ICE candidates
|
|
707
|
-
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
708
|
-
// 6. Set remote description
|
|
709
|
-
await pc.setRemoteDescription({
|
|
710
|
-
type: 'offer',
|
|
711
|
-
sdp: serviceData.sdp
|
|
712
|
-
});
|
|
713
|
-
// 7. Create and send answer
|
|
714
|
-
const answer = await pc.createAnswer();
|
|
715
|
-
await pc.setLocalDescription(answer);
|
|
716
|
-
await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
|
|
717
|
-
// 8. Wait for data channel to be established
|
|
718
|
-
dc = await dataChannelPromise;
|
|
719
|
-
// Create connection context
|
|
720
|
-
const context = {
|
|
721
|
-
pc,
|
|
722
|
-
dc,
|
|
594
|
+
// Create AnswererConnection
|
|
595
|
+
const connection = new AnswererConnection({
|
|
596
|
+
api: this.api,
|
|
723
597
|
serviceFqn: serviceData.serviceFqn,
|
|
724
598
|
offerId: serviceData.offerId,
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (dc.readyState === 'open') {
|
|
736
|
-
this.debug('Data channel already open');
|
|
737
|
-
if (onConnection) {
|
|
738
|
-
await onConnection(context);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
await new Promise((resolve) => {
|
|
743
|
-
dc.addEventListener('open', async () => {
|
|
744
|
-
this.debug('Data channel opened');
|
|
745
|
-
if (onConnection) {
|
|
746
|
-
await onConnection(context);
|
|
747
|
-
}
|
|
748
|
-
resolve();
|
|
749
|
-
});
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
return context;
|
|
599
|
+
offerSdp: serviceData.sdp,
|
|
600
|
+
rtcConfig: rtcConfiguration,
|
|
601
|
+
config: {
|
|
602
|
+
...connectionConfig,
|
|
603
|
+
debug: this.debugEnabled,
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
// Initialize the connection
|
|
607
|
+
await connection.initialize();
|
|
608
|
+
return connection;
|
|
753
609
|
}
|
|
754
610
|
// ============================================
|
|
755
611
|
// Service Discovery
|
|
@@ -757,8 +613,6 @@ export class Rondevu extends EventEmitter {
|
|
|
757
613
|
/**
|
|
758
614
|
* Find a service - unified discovery method
|
|
759
615
|
*
|
|
760
|
-
* Replaces getService(), discoverService(), and discoverServices() with a single method.
|
|
761
|
-
*
|
|
762
616
|
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
763
617
|
* @param options - Discovery options
|
|
764
618
|
*
|
|
@@ -847,6 +701,34 @@ export class Rondevu extends EventEmitter {
|
|
|
847
701
|
getPublicKey() {
|
|
848
702
|
return this.keypair.publicKey;
|
|
849
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Get active connections (for offerer side)
|
|
706
|
+
*/
|
|
707
|
+
getActiveConnections() {
|
|
708
|
+
return this.activeConnections;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Get all active offers (legacy compatibility)
|
|
712
|
+
* @deprecated Use getActiveConnections() instead
|
|
713
|
+
*/
|
|
714
|
+
getActiveOffers() {
|
|
715
|
+
const offers = [];
|
|
716
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
717
|
+
const pc = connection.getPeerConnection();
|
|
718
|
+
const dc = connection.getDataChannel();
|
|
719
|
+
if (pc) {
|
|
720
|
+
offers.push({
|
|
721
|
+
offerId,
|
|
722
|
+
serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
|
|
723
|
+
pc,
|
|
724
|
+
dc: dc || undefined,
|
|
725
|
+
answered: connection.getState() === 'connected',
|
|
726
|
+
createdAt: Date.now(),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return offers;
|
|
731
|
+
}
|
|
850
732
|
/**
|
|
851
733
|
* Access to underlying API for advanced operations
|
|
852
734
|
* @deprecated Use direct methods on Rondevu instance instead
|