@xtr-dev/rondevu-client 0.18.7 → 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 +185 -290
- 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,41 +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
|
-
// Process answers
|
|
413
|
+
// Process answers - delegate to OffererConnections
|
|
469
414
|
for (const answer of result.answers) {
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
// Create replacement offer
|
|
481
|
-
this.fillOffers();
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
// Process ICE candidates
|
|
485
|
-
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
|
|
486
|
-
const activeOffer = this.activeOffers.get(offerId);
|
|
487
|
-
if (activeOffer) {
|
|
488
|
-
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
489
|
-
for (const item of answererCandidates) {
|
|
490
|
-
if (item.candidate) {
|
|
491
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
492
|
-
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
493
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
494
|
-
}
|
|
415
|
+
const connection = this.activeConnections.get(answer.offerId);
|
|
416
|
+
if (connection) {
|
|
417
|
+
try {
|
|
418
|
+
await connection.processAnswer(answer.sdp, answer.answererId);
|
|
419
|
+
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
|
|
420
|
+
// Create replacement offer
|
|
421
|
+
this.fillOffers();
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err);
|
|
495
425
|
}
|
|
496
426
|
}
|
|
497
427
|
}
|
|
@@ -499,9 +429,6 @@ export class Rondevu extends EventEmitter {
|
|
|
499
429
|
catch (err) {
|
|
500
430
|
console.error('[Rondevu] Polling error:', err);
|
|
501
431
|
}
|
|
502
|
-
finally {
|
|
503
|
-
this.isPolling = false;
|
|
504
|
-
}
|
|
505
432
|
}
|
|
506
433
|
/**
|
|
507
434
|
* Start filling offers and polling for answers/ICE
|
|
@@ -531,35 +458,34 @@ export class Rondevu extends EventEmitter {
|
|
|
531
458
|
stopFilling() {
|
|
532
459
|
this.debug('Stopping offer filling and polling');
|
|
533
460
|
this.filling = false;
|
|
534
|
-
this.
|
|
461
|
+
this.fillingSemaphore = false;
|
|
535
462
|
// Stop polling
|
|
536
463
|
if (this.pollingInterval) {
|
|
537
464
|
clearInterval(this.pollingInterval);
|
|
538
465
|
this.pollingInterval = null;
|
|
539
466
|
}
|
|
540
467
|
// Close all active connections
|
|
541
|
-
for (const [offerId,
|
|
542
|
-
this.debug(`Closing
|
|
543
|
-
|
|
544
|
-
offer.pc.close();
|
|
468
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
469
|
+
this.debug(`Closing connection ${offerId}`);
|
|
470
|
+
connection.close();
|
|
545
471
|
}
|
|
546
|
-
this.
|
|
472
|
+
this.activeConnections.clear();
|
|
547
473
|
}
|
|
548
474
|
/**
|
|
549
475
|
* Get the count of active offers
|
|
550
476
|
* @returns Number of active offers
|
|
551
477
|
*/
|
|
552
478
|
getOfferCount() {
|
|
553
|
-
return this.
|
|
479
|
+
return this.activeConnections.size;
|
|
554
480
|
}
|
|
555
481
|
/**
|
|
556
482
|
* Check if an offer is currently connected
|
|
557
483
|
* @param offerId - The offer ID to check
|
|
558
|
-
* @returns True if the offer exists and
|
|
484
|
+
* @returns True if the offer exists and is connected
|
|
559
485
|
*/
|
|
560
486
|
isConnected(offerId) {
|
|
561
|
-
const
|
|
562
|
-
return
|
|
487
|
+
const connection = this.activeConnections.get(offerId);
|
|
488
|
+
return connection ? connection.getState() === 'connected' : false;
|
|
563
489
|
}
|
|
564
490
|
/**
|
|
565
491
|
* Disconnect all active offers
|
|
@@ -567,12 +493,11 @@ export class Rondevu extends EventEmitter {
|
|
|
567
493
|
*/
|
|
568
494
|
async disconnectAll() {
|
|
569
495
|
this.debug('Disconnecting all offers');
|
|
570
|
-
for (const [offerId,
|
|
571
|
-
this.debug(`Closing
|
|
572
|
-
|
|
573
|
-
offer.pc.close();
|
|
496
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
497
|
+
this.debug(`Closing connection ${offerId}`);
|
|
498
|
+
connection.close();
|
|
574
499
|
}
|
|
575
|
-
this.
|
|
500
|
+
this.activeConnections.clear();
|
|
576
501
|
}
|
|
577
502
|
/**
|
|
578
503
|
* Get the current service status
|
|
@@ -581,7 +506,7 @@ export class Rondevu extends EventEmitter {
|
|
|
581
506
|
getServiceStatus() {
|
|
582
507
|
return {
|
|
583
508
|
active: this.currentService !== null,
|
|
584
|
-
offerCount: this.
|
|
509
|
+
offerCount: this.activeConnections.size,
|
|
585
510
|
maxOffers: this.maxOffers,
|
|
586
511
|
filling: this.filling
|
|
587
512
|
};
|
|
@@ -609,54 +534,43 @@ export class Rondevu extends EventEmitter {
|
|
|
609
534
|
}
|
|
610
535
|
}
|
|
611
536
|
/**
|
|
612
|
-
*
|
|
613
|
-
* Returns
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
let lastIceTimestamp = 0;
|
|
617
|
-
return setInterval(async () => {
|
|
618
|
-
try {
|
|
619
|
-
const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
|
|
620
|
-
for (const item of result.candidates) {
|
|
621
|
-
if (item.candidate) {
|
|
622
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
623
|
-
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
624
|
-
lastIceTimestamp = item.createdAt;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
catch (err) {
|
|
629
|
-
console.error('[Rondevu] Failed to poll ICE candidates:', err);
|
|
630
|
-
}
|
|
631
|
-
}, Rondevu.POLLING_INTERVAL_MS);
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Automatically connect to a service (answerer side)
|
|
635
|
-
* 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
|
|
636
541
|
*
|
|
637
542
|
* @example
|
|
638
543
|
* ```typescript
|
|
639
544
|
* // Connect to specific user
|
|
640
545
|
* const connection = await rondevu.connectToService({
|
|
641
546
|
* serviceFqn: 'chat:2.0.0@alice',
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* dc.addEventListener('open', () => dc.send('Hello!'))
|
|
547
|
+
* connectionConfig: {
|
|
548
|
+
* reconnectEnabled: true,
|
|
549
|
+
* bufferEnabled: true
|
|
646
550
|
* }
|
|
647
551
|
* })
|
|
648
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
|
+
*
|
|
649
566
|
* // Discover random service
|
|
650
567
|
* const connection = await rondevu.connectToService({
|
|
651
|
-
* service: 'chat:2.0.0'
|
|
652
|
-
* onConnection: ({ dc, peerUsername }) => {
|
|
653
|
-
* console.log('Connected to', peerUsername)
|
|
654
|
-
* }
|
|
568
|
+
* service: 'chat:2.0.0'
|
|
655
569
|
* })
|
|
656
570
|
* ```
|
|
657
571
|
*/
|
|
658
572
|
async connectToService(options) {
|
|
659
|
-
const {
|
|
573
|
+
const { rtcConfig, connectionConfig } = options;
|
|
660
574
|
// Validate inputs
|
|
661
575
|
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
662
576
|
throw new Error('serviceFqn cannot be empty');
|
|
@@ -670,73 +584,28 @@ export class Rondevu extends EventEmitter {
|
|
|
670
584
|
// Determine the full service FQN
|
|
671
585
|
const fqn = await this.resolveServiceFqn(options);
|
|
672
586
|
this.debug(`Connecting to service: ${fqn}`);
|
|
673
|
-
//
|
|
587
|
+
// Get service offer
|
|
674
588
|
const serviceData = await this.api.getService(fqn);
|
|
675
589
|
this.debug(`Found service from @${serviceData.username}`);
|
|
676
|
-
//
|
|
590
|
+
// Create RTCConfiguration
|
|
677
591
|
const rtcConfiguration = rtcConfig || {
|
|
678
592
|
iceServers: this.iceServers
|
|
679
593
|
};
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const dataChannelPromise = new Promise((resolve) => {
|
|
684
|
-
pc.ondatachannel = (event) => {
|
|
685
|
-
this.debug('Data channel received from offerer');
|
|
686
|
-
dc = event.channel;
|
|
687
|
-
this.emit('connection:opened', serviceData.offerId, dc);
|
|
688
|
-
resolve(dc);
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
// 4. Set up ICE candidate exchange
|
|
692
|
-
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
693
|
-
// 5. Poll for remote ICE candidates
|
|
694
|
-
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
695
|
-
// 6. Set remote description
|
|
696
|
-
await pc.setRemoteDescription({
|
|
697
|
-
type: 'offer',
|
|
698
|
-
sdp: serviceData.sdp
|
|
699
|
-
});
|
|
700
|
-
// 7. Create and send answer
|
|
701
|
-
const answer = await pc.createAnswer();
|
|
702
|
-
await pc.setLocalDescription(answer);
|
|
703
|
-
await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
|
|
704
|
-
// 8. Wait for data channel to be established
|
|
705
|
-
dc = await dataChannelPromise;
|
|
706
|
-
// Create connection context
|
|
707
|
-
const context = {
|
|
708
|
-
pc,
|
|
709
|
-
dc,
|
|
594
|
+
// Create AnswererConnection
|
|
595
|
+
const connection = new AnswererConnection({
|
|
596
|
+
api: this.api,
|
|
710
597
|
serviceFqn: serviceData.serviceFqn,
|
|
711
598
|
offerId: serviceData.offerId,
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (dc.readyState === 'open') {
|
|
723
|
-
this.debug('Data channel already open');
|
|
724
|
-
if (onConnection) {
|
|
725
|
-
await onConnection(context);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
else {
|
|
729
|
-
await new Promise((resolve) => {
|
|
730
|
-
dc.addEventListener('open', async () => {
|
|
731
|
-
this.debug('Data channel opened');
|
|
732
|
-
if (onConnection) {
|
|
733
|
-
await onConnection(context);
|
|
734
|
-
}
|
|
735
|
-
resolve();
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
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;
|
|
740
609
|
}
|
|
741
610
|
// ============================================
|
|
742
611
|
// Service Discovery
|
|
@@ -744,8 +613,6 @@ export class Rondevu extends EventEmitter {
|
|
|
744
613
|
/**
|
|
745
614
|
* Find a service - unified discovery method
|
|
746
615
|
*
|
|
747
|
-
* Replaces getService(), discoverService(), and discoverServices() with a single method.
|
|
748
|
-
*
|
|
749
616
|
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
750
617
|
* @param options - Discovery options
|
|
751
618
|
*
|
|
@@ -834,6 +701,34 @@ export class Rondevu extends EventEmitter {
|
|
|
834
701
|
getPublicKey() {
|
|
835
702
|
return this.keypair.publicKey;
|
|
836
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
|
+
}
|
|
837
732
|
/**
|
|
838
733
|
* Access to underlying API for advanced operations
|
|
839
734
|
* @deprecated Use direct methods on Rondevu instance instead
|