@xtr-dev/rondevu-client 0.18.7 → 0.18.9
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 +54 -0
- package/dist/offerer-connection.js +177 -0
- package/dist/rondevu.d.ts +77 -64
- package/dist/rondevu.js +186 -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,69 @@ 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 with already-created PC and DC
|
|
344
|
+
const connection = new OffererConnection({
|
|
345
|
+
api: this.api,
|
|
401
346
|
serviceFqn,
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
347
|
+
offerId,
|
|
348
|
+
pc, // Pass the peer connection from factory
|
|
349
|
+
dc, // Pass the data channel from factory
|
|
350
|
+
config: {
|
|
351
|
+
...this.connectionConfig,
|
|
352
|
+
debug: this.debugEnabled,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
// Setup connection event handlers
|
|
356
|
+
connection.on('connected', () => {
|
|
357
|
+
this.debug(`Connection established for offer ${offerId}`);
|
|
358
|
+
this.emit('connection:opened', offerId, connection);
|
|
359
|
+
});
|
|
360
|
+
connection.on('failed', (error) => {
|
|
361
|
+
this.debug(`Connection failed for offer ${offerId}:`, error);
|
|
362
|
+
this.activeConnections.delete(offerId);
|
|
363
|
+
this.fillOffers(); // Replace failed offer
|
|
406
364
|
});
|
|
365
|
+
connection.on('closed', () => {
|
|
366
|
+
this.debug(`Connection closed for offer ${offerId}`);
|
|
367
|
+
this.activeConnections.delete(offerId);
|
|
368
|
+
this.fillOffers(); // Replace closed offer
|
|
369
|
+
});
|
|
370
|
+
// Store active connection
|
|
371
|
+
this.activeConnections.set(offerId, connection);
|
|
372
|
+
// Initialize the connection
|
|
373
|
+
await connection.initialize();
|
|
407
374
|
this.debug(`Offer created: ${offerId}`);
|
|
408
375
|
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
376
|
}
|
|
436
377
|
/**
|
|
437
|
-
* Fill offers to reach maxOffers count
|
|
378
|
+
* Fill offers to reach maxOffers count with semaphore protection
|
|
438
379
|
*/
|
|
439
380
|
async fillOffers() {
|
|
440
381
|
if (!this.filling || !this.currentService)
|
|
441
382
|
return;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
383
|
+
// Semaphore to prevent concurrent fills
|
|
384
|
+
if (this.fillingSemaphore) {
|
|
385
|
+
this.debug('fillOffers already in progress, skipping');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.fillingSemaphore = true;
|
|
389
|
+
try {
|
|
390
|
+
const currentCount = this.activeConnections.size;
|
|
391
|
+
const needed = this.maxOffers - currentCount;
|
|
392
|
+
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
|
|
393
|
+
for (let i = 0; i < needed; i++) {
|
|
394
|
+
try {
|
|
395
|
+
await this.createOffer();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
console.error('[Rondevu] Failed to create offer:', err);
|
|
399
|
+
}
|
|
451
400
|
}
|
|
452
401
|
}
|
|
402
|
+
finally {
|
|
403
|
+
this.fillingSemaphore = false;
|
|
404
|
+
}
|
|
453
405
|
}
|
|
454
406
|
/**
|
|
455
407
|
* Poll for answers and ICE candidates (internal use for automatic offer management)
|
|
@@ -457,41 +409,20 @@ export class Rondevu extends EventEmitter {
|
|
|
457
409
|
async pollInternal() {
|
|
458
410
|
if (!this.filling)
|
|
459
411
|
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
412
|
try {
|
|
467
413
|
const result = await this.api.poll(this.lastPollTimestamp);
|
|
468
|
-
// Process answers
|
|
414
|
+
// Process answers - delegate to OffererConnections
|
|
469
415
|
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
|
-
}
|
|
416
|
+
const connection = this.activeConnections.get(answer.offerId);
|
|
417
|
+
if (connection) {
|
|
418
|
+
try {
|
|
419
|
+
await connection.processAnswer(answer.sdp, answer.answererId);
|
|
420
|
+
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
|
|
421
|
+
// Create replacement offer
|
|
422
|
+
this.fillOffers();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err);
|
|
495
426
|
}
|
|
496
427
|
}
|
|
497
428
|
}
|
|
@@ -499,9 +430,6 @@ export class Rondevu extends EventEmitter {
|
|
|
499
430
|
catch (err) {
|
|
500
431
|
console.error('[Rondevu] Polling error:', err);
|
|
501
432
|
}
|
|
502
|
-
finally {
|
|
503
|
-
this.isPolling = false;
|
|
504
|
-
}
|
|
505
433
|
}
|
|
506
434
|
/**
|
|
507
435
|
* Start filling offers and polling for answers/ICE
|
|
@@ -531,35 +459,34 @@ export class Rondevu extends EventEmitter {
|
|
|
531
459
|
stopFilling() {
|
|
532
460
|
this.debug('Stopping offer filling and polling');
|
|
533
461
|
this.filling = false;
|
|
534
|
-
this.
|
|
462
|
+
this.fillingSemaphore = false;
|
|
535
463
|
// Stop polling
|
|
536
464
|
if (this.pollingInterval) {
|
|
537
465
|
clearInterval(this.pollingInterval);
|
|
538
466
|
this.pollingInterval = null;
|
|
539
467
|
}
|
|
540
468
|
// Close all active connections
|
|
541
|
-
for (const [offerId,
|
|
542
|
-
this.debug(`Closing
|
|
543
|
-
|
|
544
|
-
offer.pc.close();
|
|
469
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
470
|
+
this.debug(`Closing connection ${offerId}`);
|
|
471
|
+
connection.close();
|
|
545
472
|
}
|
|
546
|
-
this.
|
|
473
|
+
this.activeConnections.clear();
|
|
547
474
|
}
|
|
548
475
|
/**
|
|
549
476
|
* Get the count of active offers
|
|
550
477
|
* @returns Number of active offers
|
|
551
478
|
*/
|
|
552
479
|
getOfferCount() {
|
|
553
|
-
return this.
|
|
480
|
+
return this.activeConnections.size;
|
|
554
481
|
}
|
|
555
482
|
/**
|
|
556
483
|
* Check if an offer is currently connected
|
|
557
484
|
* @param offerId - The offer ID to check
|
|
558
|
-
* @returns True if the offer exists and
|
|
485
|
+
* @returns True if the offer exists and is connected
|
|
559
486
|
*/
|
|
560
487
|
isConnected(offerId) {
|
|
561
|
-
const
|
|
562
|
-
return
|
|
488
|
+
const connection = this.activeConnections.get(offerId);
|
|
489
|
+
return connection ? connection.getState() === 'connected' : false;
|
|
563
490
|
}
|
|
564
491
|
/**
|
|
565
492
|
* Disconnect all active offers
|
|
@@ -567,12 +494,11 @@ export class Rondevu extends EventEmitter {
|
|
|
567
494
|
*/
|
|
568
495
|
async disconnectAll() {
|
|
569
496
|
this.debug('Disconnecting all offers');
|
|
570
|
-
for (const [offerId,
|
|
571
|
-
this.debug(`Closing
|
|
572
|
-
|
|
573
|
-
offer.pc.close();
|
|
497
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
498
|
+
this.debug(`Closing connection ${offerId}`);
|
|
499
|
+
connection.close();
|
|
574
500
|
}
|
|
575
|
-
this.
|
|
501
|
+
this.activeConnections.clear();
|
|
576
502
|
}
|
|
577
503
|
/**
|
|
578
504
|
* Get the current service status
|
|
@@ -581,7 +507,7 @@ export class Rondevu extends EventEmitter {
|
|
|
581
507
|
getServiceStatus() {
|
|
582
508
|
return {
|
|
583
509
|
active: this.currentService !== null,
|
|
584
|
-
offerCount: this.
|
|
510
|
+
offerCount: this.activeConnections.size,
|
|
585
511
|
maxOffers: this.maxOffers,
|
|
586
512
|
filling: this.filling
|
|
587
513
|
};
|
|
@@ -609,54 +535,43 @@ export class Rondevu extends EventEmitter {
|
|
|
609
535
|
}
|
|
610
536
|
}
|
|
611
537
|
/**
|
|
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
|
|
538
|
+
* Connect to a service (answerer side) - v1.0.0 API
|
|
539
|
+
* Returns an AnswererConnection with automatic reconnection and buffering
|
|
540
|
+
*
|
|
541
|
+
* BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
|
|
636
542
|
*
|
|
637
543
|
* @example
|
|
638
544
|
* ```typescript
|
|
639
545
|
* // Connect to specific user
|
|
640
546
|
* const connection = await rondevu.connectToService({
|
|
641
547
|
* serviceFqn: 'chat:2.0.0@alice',
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* dc.addEventListener('open', () => dc.send('Hello!'))
|
|
548
|
+
* connectionConfig: {
|
|
549
|
+
* reconnectEnabled: true,
|
|
550
|
+
* bufferEnabled: true
|
|
646
551
|
* }
|
|
647
552
|
* })
|
|
648
553
|
*
|
|
554
|
+
* connection.on('connected', () => {
|
|
555
|
+
* console.log('Connected!')
|
|
556
|
+
* connection.send('Hello!')
|
|
557
|
+
* })
|
|
558
|
+
*
|
|
559
|
+
* connection.on('message', (data) => {
|
|
560
|
+
* console.log('Received:', data)
|
|
561
|
+
* })
|
|
562
|
+
*
|
|
563
|
+
* connection.on('reconnecting', (attempt) => {
|
|
564
|
+
* console.log(`Reconnecting, attempt ${attempt}`)
|
|
565
|
+
* })
|
|
566
|
+
*
|
|
649
567
|
* // Discover random service
|
|
650
568
|
* const connection = await rondevu.connectToService({
|
|
651
|
-
* service: 'chat:2.0.0'
|
|
652
|
-
* onConnection: ({ dc, peerUsername }) => {
|
|
653
|
-
* console.log('Connected to', peerUsername)
|
|
654
|
-
* }
|
|
569
|
+
* service: 'chat:2.0.0'
|
|
655
570
|
* })
|
|
656
571
|
* ```
|
|
657
572
|
*/
|
|
658
573
|
async connectToService(options) {
|
|
659
|
-
const {
|
|
574
|
+
const { rtcConfig, connectionConfig } = options;
|
|
660
575
|
// Validate inputs
|
|
661
576
|
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
662
577
|
throw new Error('serviceFqn cannot be empty');
|
|
@@ -670,73 +585,28 @@ export class Rondevu extends EventEmitter {
|
|
|
670
585
|
// Determine the full service FQN
|
|
671
586
|
const fqn = await this.resolveServiceFqn(options);
|
|
672
587
|
this.debug(`Connecting to service: ${fqn}`);
|
|
673
|
-
//
|
|
588
|
+
// Get service offer
|
|
674
589
|
const serviceData = await this.api.getService(fqn);
|
|
675
590
|
this.debug(`Found service from @${serviceData.username}`);
|
|
676
|
-
//
|
|
591
|
+
// Create RTCConfiguration
|
|
677
592
|
const rtcConfiguration = rtcConfig || {
|
|
678
593
|
iceServers: this.iceServers
|
|
679
594
|
};
|
|
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,
|
|
595
|
+
// Create AnswererConnection
|
|
596
|
+
const connection = new AnswererConnection({
|
|
597
|
+
api: this.api,
|
|
710
598
|
serviceFqn: serviceData.serviceFqn,
|
|
711
599
|
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;
|
|
600
|
+
offerSdp: serviceData.sdp,
|
|
601
|
+
rtcConfig: rtcConfiguration,
|
|
602
|
+
config: {
|
|
603
|
+
...connectionConfig,
|
|
604
|
+
debug: this.debugEnabled,
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
// Initialize the connection
|
|
608
|
+
await connection.initialize();
|
|
609
|
+
return connection;
|
|
740
610
|
}
|
|
741
611
|
// ============================================
|
|
742
612
|
// Service Discovery
|
|
@@ -744,8 +614,6 @@ export class Rondevu extends EventEmitter {
|
|
|
744
614
|
/**
|
|
745
615
|
* Find a service - unified discovery method
|
|
746
616
|
*
|
|
747
|
-
* Replaces getService(), discoverService(), and discoverServices() with a single method.
|
|
748
|
-
*
|
|
749
617
|
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
750
618
|
* @param options - Discovery options
|
|
751
619
|
*
|
|
@@ -834,6 +702,34 @@ export class Rondevu extends EventEmitter {
|
|
|
834
702
|
getPublicKey() {
|
|
835
703
|
return this.keypair.publicKey;
|
|
836
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* Get active connections (for offerer side)
|
|
707
|
+
*/
|
|
708
|
+
getActiveConnections() {
|
|
709
|
+
return this.activeConnections;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Get all active offers (legacy compatibility)
|
|
713
|
+
* @deprecated Use getActiveConnections() instead
|
|
714
|
+
*/
|
|
715
|
+
getActiveOffers() {
|
|
716
|
+
const offers = [];
|
|
717
|
+
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
718
|
+
const pc = connection.getPeerConnection();
|
|
719
|
+
const dc = connection.getDataChannel();
|
|
720
|
+
if (pc) {
|
|
721
|
+
offers.push({
|
|
722
|
+
offerId,
|
|
723
|
+
serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
|
|
724
|
+
pc,
|
|
725
|
+
dc: dc || undefined,
|
|
726
|
+
answered: connection.getState() === 'connected',
|
|
727
|
+
createdAt: Date.now(),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return offers;
|
|
732
|
+
}
|
|
837
733
|
/**
|
|
838
734
|
* Access to underlying API for advanced operations
|
|
839
735
|
* @deprecated Use direct methods on Rondevu instance instead
|