@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/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
- * Provides a unified API for:
92
- * - Implicit username claiming (auto-claimed on first authenticated request)
93
- * - Service publishing with automatic signature generation
94
- * - Service discovery (direct, random, paginated)
95
- * - WebRTC signaling (offer/answer exchange, ICE relay)
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, // Maintain up to 5 concurrent offers
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
- * // Access active connections
134
- * for (const offer of rondevu.getActiveOffers()) {
135
- * offer.dc?.addEventListener('message', (e) => console.log(e.data))
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
- * // Stop when done
139
- * rondevu.stopFilling()
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.activeOffers = new Map();
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
- * Set up ICE candidate handler to send candidates to the server
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 the RTCPeerConnection - Rondevu controls this to set up handlers early
320
+ // 1. Create RTCPeerConnection using factory (for now, keep compatibility)
342
321
  const pc = new RTCPeerConnection(rtcConfig);
343
- // 2. Set up ICE candidate handler with queuing BEFORE the factory runs
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
- // 4. Publish to server to get offerId
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
- // 5. Store active offer
399
- this.activeOffers.set(offerId, {
400
- offerId,
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
- pc,
403
- dc,
404
- answered: false,
405
- createdAt: Date.now()
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
- const currentCount = this.activeOffers.size;
443
- const needed = this.maxOffers - currentCount;
444
- this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
445
- for (let i = 0; i < needed; i++) {
446
- try {
447
- await this.createOffer();
448
- }
449
- catch (err) {
450
- console.error('[Rondevu] Failed to create offer:', err);
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
- // Update timestamp FIRST to prevent re-fetching same answers if processing fails
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 activeOffer = this.activeOffers.get(answer.offerId);
476
- if (activeOffer && !activeOffer.answered) {
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 activeOffer.pc.setRemoteDescription({
482
- type: 'answer',
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
- // If setRemoteDescription fails, reset the answered flag
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.isPolling = false; // Reset polling guard
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, offer] of this.activeOffers.entries()) {
555
- this.debug(`Closing offer ${offerId}`);
556
- offer.dc?.close();
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.activeOffers.clear();
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.activeOffers.size;
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 has been answered
484
+ * @returns True if the offer exists and is connected
572
485
  */
573
486
  isConnected(offerId) {
574
- const offer = this.activeOffers.get(offerId);
575
- return offer ? offer.answered : false;
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, offer] of this.activeOffers.entries()) {
584
- this.debug(`Closing offer ${offerId}`);
585
- offer.dc?.close();
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.activeOffers.clear();
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.activeOffers.size,
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
- * Start polling for remote ICE candidates
626
- * Returns the polling interval ID
627
- */
628
- startIcePolling(pc, serviceFqn, offerId) {
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
- * onConnection: ({ dc, peerUsername }) => {
656
- * console.log('Connected to', peerUsername)
657
- * dc.addEventListener('message', (e) => console.log(e.data))
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 { onConnection, rtcConfig } = options;
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
- // 1. Get service offer
587
+ // Get service offer
687
588
  const serviceData = await this.api.getService(fqn);
688
589
  this.debug(`Found service from @${serviceData.username}`);
689
- // 2. Create RTCPeerConnection
590
+ // Create RTCConfiguration
690
591
  const rtcConfiguration = rtcConfig || {
691
592
  iceServers: this.iceServers
692
593
  };
693
- const pc = new RTCPeerConnection(rtcConfiguration);
694
- // 3. Set up data channel handler (answerer receives it from offerer)
695
- let dc = null;
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
- peerUsername: serviceData.username
726
- };
727
- // 9. Set up connection state monitoring
728
- pc.onconnectionstatechange = () => {
729
- this.debug(`Connection state: ${pc.connectionState}`);
730
- if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
731
- clearInterval(icePollInterval);
732
- }
733
- };
734
- // 10. Wait for data channel to open and call onConnection
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