@xtr-dev/rondevu-client 0.18.9 → 0.18.10

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/index.d.ts CHANGED
@@ -5,18 +5,9 @@
5
5
  export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
6
6
  export { RondevuAPI } from './api.js';
7
7
  export { RpcBatcher } from './rpc-batcher.js';
8
- export { RondevuConnection } from './connection.js';
9
- export { OffererConnection } from './offerer-connection.js';
10
- export { AnswererConnection } from './answerer-connection.js';
11
- export { ExponentialBackoff } from './exponential-backoff.js';
12
- export { MessageBuffer } from './message-buffer.js';
13
8
  export { WebCryptoAdapter } from './web-crypto-adapter.js';
14
9
  export { NodeCryptoAdapter } from './node-crypto-adapter.js';
15
10
  export type { Signaler, Binnable, } from './types.js';
16
11
  export type { Keypair, OfferRequest, ServiceRequest, Service, ServiceOffer, IceCandidate, } from './api.js';
17
12
  export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory, ActiveOffer, FindServiceOptions, ServiceResult, PaginatedServiceResult } from './rondevu.js';
18
13
  export type { CryptoAdapter } from './crypto-adapter.js';
19
- export type { ConnectionConfig, } from './connection-config.js';
20
- export type { ConnectionState, BufferedMessage, ReconnectInfo, StateChangeInfo, ConnectionEventMap, ConnectionEventName, ConnectionEventArgs, } from './connection-events.js';
21
- export type { OffererOptions, } from './offerer-connection.js';
22
- export type { AnswererOptions, } from './answerer-connection.js';
package/dist/index.js CHANGED
@@ -5,13 +5,6 @@
5
5
  export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
6
6
  export { RondevuAPI } from './api.js';
7
7
  export { RpcBatcher } from './rpc-batcher.js';
8
- // Export connection classes
9
- export { RondevuConnection } from './connection.js';
10
- export { OffererConnection } from './offerer-connection.js';
11
- export { AnswererConnection } from './answerer-connection.js';
12
- // Export utilities
13
- export { ExponentialBackoff } from './exponential-backoff.js';
14
- export { MessageBuffer } from './message-buffer.js';
15
8
  // Export crypto adapters
16
9
  export { WebCryptoAdapter } from './web-crypto-adapter.js';
17
10
  export { NodeCryptoAdapter } from './node-crypto-adapter.js';
package/dist/rondevu.d.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js';
2
2
  import { CryptoAdapter } from './crypto-adapter.js';
3
3
  import { EventEmitter } from 'eventemitter3';
4
- import { OffererConnection } from './offerer-connection.js';
5
- import { AnswererConnection } from './answerer-connection.js';
6
- import { ConnectionConfig } from './connection-config.js';
7
4
  export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only';
8
5
  export declare const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]>;
9
6
  export interface RondevuOptions {
@@ -35,7 +32,6 @@ export interface PublishServiceOptions {
35
32
  maxOffers: number;
36
33
  offerFactory?: OfferFactory;
37
34
  ttl?: number;
38
- connectionConfig?: Partial<ConnectionConfig>;
39
35
  }
40
36
  export interface ConnectionContext {
41
37
  pc: RTCPeerConnection;
@@ -48,8 +44,8 @@ export interface ConnectToServiceOptions {
48
44
  serviceFqn?: string;
49
45
  service?: string;
50
46
  username?: string;
47
+ onConnection?: (context: ConnectionContext) => void | Promise<void>;
51
48
  rtcConfig?: RTCConfiguration;
52
- connectionConfig?: Partial<ConnectionConfig>;
53
49
  }
54
50
  export interface ActiveOffer {
55
51
  offerId: string;
@@ -105,13 +101,14 @@ export declare class ConnectionError extends RondevuError {
105
101
  constructor(message: string, context?: Record<string, any>);
106
102
  }
107
103
  /**
108
- * Rondevu - Complete WebRTC signaling client with durable connections
104
+ * Rondevu - Complete WebRTC signaling client
109
105
  *
110
- * v1.0.0 introduces breaking changes:
111
- * - connectToService() now returns AnswererConnection instead of ConnectionContext
112
- * - Automatic reconnection and message buffering built-in
113
- * - Connection objects expose .send() method instead of raw DataChannel
114
- * - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
106
+ * Provides a unified API for:
107
+ * - Implicit username claiming (auto-claimed on first authenticated request)
108
+ * - Service publishing with automatic signature generation
109
+ * - Service discovery (direct, random, paginated)
110
+ * - WebRTC signaling (offer/answer exchange, ICE relay)
111
+ * - Keypair management
115
112
  *
116
113
  * @example
117
114
  * ```typescript
@@ -122,39 +119,39 @@ export declare class ConnectionError extends RondevuError {
122
119
  * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
123
120
  * })
124
121
  *
122
+ * // Or use custom ICE servers
123
+ * const rondevu2 = await Rondevu.connect({
124
+ * apiUrl: 'https://signal.example.com',
125
+ * username: 'bob',
126
+ * iceServers: [
127
+ * { urls: 'stun:stun.l.google.com:19302' },
128
+ * { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
129
+ * ]
130
+ * })
131
+ *
125
132
  * // Publish a service with automatic offer management
126
133
  * await rondevu.publishService({
127
134
  * service: 'chat:2.0.0',
128
- * maxOffers: 5 // Maintain up to 5 concurrent offers
135
+ * maxOffers: 5, // Maintain up to 5 concurrent offers
136
+ * offerFactory: async (pc) => {
137
+ * // pc is created by Rondevu with ICE handlers already attached
138
+ * const dc = pc.createDataChannel('chat')
139
+ * const offer = await pc.createOffer()
140
+ * await pc.setLocalDescription(offer)
141
+ * return { dc, offer }
142
+ * }
129
143
  * })
130
144
  *
131
145
  * // Start accepting connections (auto-fills offers and polls)
132
146
  * await rondevu.startFilling()
133
147
  *
134
- * // Listen for connections (v1.0.0 API)
135
- * rondevu.on('connection:opened', (offerId, connection) => {
136
- * connection.on('connected', () => console.log('Connected!'))
137
- * connection.on('message', (data) => console.log('Received:', data))
138
- * connection.send('Hello!')
139
- * })
140
- *
141
- * // Connect to a service (v1.0.0 - returns AnswererConnection)
142
- * const connection = await rondevu.connectToService({
143
- * serviceFqn: 'chat:2.0.0@bob'
144
- * })
145
- *
146
- * connection.on('connected', () => {
147
- * console.log('Connected!')
148
- * connection.send('Hello!')
149
- * })
150
- *
151
- * connection.on('message', (data) => {
152
- * console.log('Received:', data)
153
- * })
148
+ * // Access active connections
149
+ * for (const offer of rondevu.getActiveOffers()) {
150
+ * offer.dc?.addEventListener('message', (e) => console.log(e.data))
151
+ * }
154
152
  *
155
- * connection.on('reconnecting', (attempt) => {
156
- * console.log(`Reconnecting, attempt ${attempt}`)
157
- * })
153
+ * // Stop when done
154
+ * rondevu.stopFilling()
158
155
  * ```
159
156
  */
160
157
  export declare class Rondevu extends EventEmitter {
@@ -175,12 +172,11 @@ export declare class Rondevu extends EventEmitter {
175
172
  private maxOffers;
176
173
  private offerFactory;
177
174
  private ttl;
178
- private activeConnections;
179
- private connectionConfig?;
175
+ private activeOffers;
180
176
  private filling;
181
- private fillingSemaphore;
182
177
  private pollingInterval;
183
178
  private lastPollTimestamp;
179
+ private isPolling;
184
180
  private constructor();
185
181
  /**
186
182
  * Internal debug logging - only logs if debug mode is enabled
@@ -219,22 +215,26 @@ export declare class Rondevu extends EventEmitter {
219
215
  * ```typescript
220
216
  * await rondevu.publishService({
221
217
  * service: 'chat:2.0.0',
222
- * maxOffers: 5,
223
- * connectionConfig: {
224
- * reconnectEnabled: true,
225
- * bufferEnabled: true
226
- * }
218
+ * maxOffers: 5
227
219
  * })
228
220
  * await rondevu.startFilling()
229
221
  * ```
230
222
  */
231
223
  publishService(options: PublishServiceOptions): Promise<void>;
232
224
  /**
233
- * Create a single offer and publish it to the server using OffererConnection
225
+ * Set up ICE candidate handler to send candidates to the server
226
+ *
227
+ * Note: This is used by connectToService() where the offerId is already known.
228
+ * For createOffer(), we use inline ICE handling with early candidate queuing
229
+ * since the offerId isn't available until after the factory completes.
230
+ */
231
+ private setupIceCandidateHandler;
232
+ /**
233
+ * Create a single offer and publish it to the server
234
234
  */
235
235
  private createOffer;
236
236
  /**
237
- * Fill offers to reach maxOffers count with semaphore protection
237
+ * Fill offers to reach maxOffers count
238
238
  */
239
239
  private fillOffers;
240
240
  /**
@@ -259,7 +259,7 @@ export declare class Rondevu extends EventEmitter {
259
259
  /**
260
260
  * Check if an offer is currently connected
261
261
  * @param offerId - The offer ID to check
262
- * @returns True if the offer exists and is connected
262
+ * @returns True if the offer exists and has been answered
263
263
  */
264
264
  isConnected(offerId: string): boolean;
265
265
  /**
@@ -283,45 +283,41 @@ export declare class Rondevu extends EventEmitter {
283
283
  */
284
284
  private resolveServiceFqn;
285
285
  /**
286
- * Connect to a service (answerer side) - v1.0.0 API
287
- * Returns an AnswererConnection with automatic reconnection and buffering
288
- *
289
- * BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
286
+ * Start polling for remote ICE candidates
287
+ * Returns the polling interval ID
288
+ */
289
+ private startIcePolling;
290
+ /**
291
+ * Automatically connect to a service (answerer side)
292
+ * Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
290
293
  *
291
294
  * @example
292
295
  * ```typescript
293
296
  * // Connect to specific user
294
297
  * const connection = await rondevu.connectToService({
295
298
  * serviceFqn: 'chat:2.0.0@alice',
296
- * connectionConfig: {
297
- * reconnectEnabled: true,
298
- * bufferEnabled: true
299
+ * onConnection: ({ dc, peerUsername }) => {
300
+ * console.log('Connected to', peerUsername)
301
+ * dc.addEventListener('message', (e) => console.log(e.data))
302
+ * dc.addEventListener('open', () => dc.send('Hello!'))
299
303
  * }
300
304
  * })
301
305
  *
302
- * connection.on('connected', () => {
303
- * console.log('Connected!')
304
- * connection.send('Hello!')
305
- * })
306
- *
307
- * connection.on('message', (data) => {
308
- * console.log('Received:', data)
309
- * })
310
- *
311
- * connection.on('reconnecting', (attempt) => {
312
- * console.log(`Reconnecting, attempt ${attempt}`)
313
- * })
314
- *
315
306
  * // Discover random service
316
307
  * const connection = await rondevu.connectToService({
317
- * service: 'chat:2.0.0'
308
+ * service: 'chat:2.0.0',
309
+ * onConnection: ({ dc, peerUsername }) => {
310
+ * console.log('Connected to', peerUsername)
311
+ * }
318
312
  * })
319
313
  * ```
320
314
  */
321
- connectToService(options: ConnectToServiceOptions): Promise<AnswererConnection>;
315
+ connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
322
316
  /**
323
317
  * Find a service - unified discovery method
324
318
  *
319
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
320
+ *
325
321
  * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
326
322
  * @param options - Discovery options
327
323
  *
@@ -403,15 +399,6 @@ export declare class Rondevu extends EventEmitter {
403
399
  * Get the public key
404
400
  */
405
401
  getPublicKey(): string;
406
- /**
407
- * Get active connections (for offerer side)
408
- */
409
- getActiveConnections(): Map<string, OffererConnection>;
410
- /**
411
- * Get all active offers (legacy compatibility)
412
- * @deprecated Use getActiveConnections() instead
413
- */
414
- getActiveOffers(): ActiveOffer[];
415
402
  /**
416
403
  * Access to underlying API for advanced operations
417
404
  * @deprecated Use direct methods on Rondevu instance instead
package/dist/rondevu.js CHANGED
@@ -1,7 +1,5 @@
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';
5
3
  // ICE server presets
6
4
  export const ICE_SERVER_PRESETS = {
7
5
  'ipv4-turn': [
@@ -88,13 +86,14 @@ export class ConnectionError extends RondevuError {
88
86
  }
89
87
  }
90
88
  /**
91
- * Rondevu - Complete WebRTC signaling client with durable connections
89
+ * Rondevu - Complete WebRTC signaling client
92
90
  *
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.)
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
98
97
  *
99
98
  * @example
100
99
  * ```typescript
@@ -105,39 +104,39 @@ export class ConnectionError extends RondevuError {
105
104
  * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
106
105
  * })
107
106
  *
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
+ *
108
117
  * // Publish a service with automatic offer management
109
118
  * await rondevu.publishService({
110
119
  * service: 'chat:2.0.0',
111
- * maxOffers: 5 // Maintain up to 5 concurrent offers
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
+ * }
112
128
  * })
113
129
  *
114
130
  * // Start accepting connections (auto-fills offers and polls)
115
131
  * await rondevu.startFilling()
116
132
  *
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
- * })
133
+ * // Access active connections
134
+ * for (const offer of rondevu.getActiveOffers()) {
135
+ * offer.dc?.addEventListener('message', (e) => console.log(e.data))
136
+ * }
123
137
  *
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
- * })
138
+ * // Stop when done
139
+ * rondevu.stopFilling()
141
140
  * ```
142
141
  */
143
142
  export class Rondevu extends EventEmitter {
@@ -149,12 +148,12 @@ export class Rondevu extends EventEmitter {
149
148
  this.maxOffers = 0;
150
149
  this.offerFactory = null;
151
150
  this.ttl = Rondevu.DEFAULT_TTL_MS;
152
- this.activeConnections = new Map();
151
+ this.activeOffers = new Map();
153
152
  // Polling
154
153
  this.filling = false;
155
- this.fillingSemaphore = false; // Semaphore to prevent concurrent fillOffers calls
156
154
  this.pollingInterval = null;
157
155
  this.lastPollTimestamp = 0;
156
+ this.isPolling = false; // Guard against concurrent poll execution
158
157
  this.apiUrl = apiUrl;
159
158
  this.username = username;
160
159
  this.keypair = keypair;
@@ -285,27 +284,49 @@ export class Rondevu extends EventEmitter {
285
284
  * ```typescript
286
285
  * await rondevu.publishService({
287
286
  * service: 'chat:2.0.0',
288
- * maxOffers: 5,
289
- * connectionConfig: {
290
- * reconnectEnabled: true,
291
- * bufferEnabled: true
292
- * }
287
+ * maxOffers: 5
293
288
  * })
294
289
  * await rondevu.startFilling()
295
290
  * ```
296
291
  */
297
292
  async publishService(options) {
298
- const { service, maxOffers, offerFactory, ttl, connectionConfig } = options;
293
+ const { service, maxOffers, offerFactory, ttl } = options;
299
294
  this.currentService = service;
300
295
  this.maxOffers = maxOffers;
301
296
  this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
302
297
  this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
303
- this.connectionConfig = connectionConfig;
304
298
  this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
305
299
  this.usernameClaimed = true;
306
300
  }
307
301
  /**
308
- * Create a single offer and publish it to the server using OffererConnection
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
309
330
  */
310
331
  async createOffer() {
311
332
  if (!this.currentService || !this.offerFactory) {
@@ -317,9 +338,42 @@ export class Rondevu extends EventEmitter {
317
338
  // Auto-append username to service
318
339
  const serviceFqn = `${this.currentService}@${this.username}`;
319
340
  this.debug('Creating new offer...');
320
- // 1. Create RTCPeerConnection using factory (for now, keep compatibility)
341
+ // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
321
342
  const pc = new RTCPeerConnection(rtcConfig);
322
- // 2. Call the factory to create offer
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
323
377
  let dc;
324
378
  let offer;
325
379
  try {
@@ -328,10 +382,11 @@ export class Rondevu extends EventEmitter {
328
382
  offer = factoryResult.offer;
329
383
  }
330
384
  catch (err) {
385
+ // Clean up the connection if factory fails
331
386
  pc.close();
332
387
  throw err;
333
388
  }
334
- // 3. Publish to server to get offerId
389
+ // 4. Publish to server to get offerId
335
390
  const result = await this.api.publishService({
336
391
  serviceFqn,
337
392
  offers: [{ sdp: offer.sdp }],
@@ -339,68 +394,61 @@ export class Rondevu extends EventEmitter {
339
394
  signature: '',
340
395
  message: '',
341
396
  });
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,
346
- serviceFqn,
397
+ offerId = result.offers[0].offerId;
398
+ // 5. Store active offer
399
+ this.activeOffers.set(offerId, {
347
400
  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
364
- });
365
- connection.on('closed', () => {
366
- this.debug(`Connection closed for offer ${offerId}`);
367
- this.activeConnections.delete(offerId);
368
- this.fillOffers(); // Replace closed offer
401
+ serviceFqn,
402
+ pc,
403
+ dc,
404
+ answered: false,
405
+ createdAt: Date.now()
369
406
  });
370
- // Store active connection
371
- this.activeConnections.set(offerId, connection);
372
- // Initialize the connection
373
- await connection.initialize();
374
407
  this.debug(`Offer created: ${offerId}`);
375
408
  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
+ };
376
435
  }
377
436
  /**
378
- * Fill offers to reach maxOffers count with semaphore protection
437
+ * Fill offers to reach maxOffers count
379
438
  */
380
439
  async fillOffers() {
381
440
  if (!this.filling || !this.currentService)
382
441
  return;
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
- }
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);
400
451
  }
401
- }
402
- finally {
403
- this.fillingSemaphore = false;
404
452
  }
405
453
  }
406
454
  /**
@@ -409,20 +457,41 @@ export class Rondevu extends EventEmitter {
409
457
  async pollInternal() {
410
458
  if (!this.filling)
411
459
  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;
412
466
  try {
413
467
  const result = await this.api.poll(this.lastPollTimestamp);
414
- // Process answers - delegate to OffererConnections
468
+ // Process answers
415
469
  for (const answer of result.answers) {
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);
470
+ const activeOffer = this.activeOffers.get(answer.offerId);
471
+ if (activeOffer && !activeOffer.answered) {
472
+ this.debug(`Received answer for offer ${answer.offerId}`);
473
+ await activeOffer.pc.setRemoteDescription({
474
+ type: 'answer',
475
+ sdp: answer.sdp
476
+ });
477
+ activeOffer.answered = true;
478
+ this.lastPollTimestamp = answer.answeredAt;
479
+ this.emit('offer:answered', answer.offerId, answer.answererId);
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
+ }
426
495
  }
427
496
  }
428
497
  }
@@ -430,6 +499,9 @@ export class Rondevu extends EventEmitter {
430
499
  catch (err) {
431
500
  console.error('[Rondevu] Polling error:', err);
432
501
  }
502
+ finally {
503
+ this.isPolling = false;
504
+ }
433
505
  }
434
506
  /**
435
507
  * Start filling offers and polling for answers/ICE
@@ -459,34 +531,35 @@ export class Rondevu extends EventEmitter {
459
531
  stopFilling() {
460
532
  this.debug('Stopping offer filling and polling');
461
533
  this.filling = false;
462
- this.fillingSemaphore = false;
534
+ this.isPolling = false; // Reset polling guard
463
535
  // Stop polling
464
536
  if (this.pollingInterval) {
465
537
  clearInterval(this.pollingInterval);
466
538
  this.pollingInterval = null;
467
539
  }
468
540
  // Close all active connections
469
- for (const [offerId, connection] of this.activeConnections.entries()) {
470
- this.debug(`Closing connection ${offerId}`);
471
- connection.close();
541
+ for (const [offerId, offer] of this.activeOffers.entries()) {
542
+ this.debug(`Closing offer ${offerId}`);
543
+ offer.dc?.close();
544
+ offer.pc.close();
472
545
  }
473
- this.activeConnections.clear();
546
+ this.activeOffers.clear();
474
547
  }
475
548
  /**
476
549
  * Get the count of active offers
477
550
  * @returns Number of active offers
478
551
  */
479
552
  getOfferCount() {
480
- return this.activeConnections.size;
553
+ return this.activeOffers.size;
481
554
  }
482
555
  /**
483
556
  * Check if an offer is currently connected
484
557
  * @param offerId - The offer ID to check
485
- * @returns True if the offer exists and is connected
558
+ * @returns True if the offer exists and has been answered
486
559
  */
487
560
  isConnected(offerId) {
488
- const connection = this.activeConnections.get(offerId);
489
- return connection ? connection.getState() === 'connected' : false;
561
+ const offer = this.activeOffers.get(offerId);
562
+ return offer ? offer.answered : false;
490
563
  }
491
564
  /**
492
565
  * Disconnect all active offers
@@ -494,11 +567,12 @@ export class Rondevu extends EventEmitter {
494
567
  */
495
568
  async disconnectAll() {
496
569
  this.debug('Disconnecting all offers');
497
- for (const [offerId, connection] of this.activeConnections.entries()) {
498
- this.debug(`Closing connection ${offerId}`);
499
- connection.close();
570
+ for (const [offerId, offer] of this.activeOffers.entries()) {
571
+ this.debug(`Closing offer ${offerId}`);
572
+ offer.dc?.close();
573
+ offer.pc.close();
500
574
  }
501
- this.activeConnections.clear();
575
+ this.activeOffers.clear();
502
576
  }
503
577
  /**
504
578
  * Get the current service status
@@ -507,7 +581,7 @@ export class Rondevu extends EventEmitter {
507
581
  getServiceStatus() {
508
582
  return {
509
583
  active: this.currentService !== null,
510
- offerCount: this.activeConnections.size,
584
+ offerCount: this.activeOffers.size,
511
585
  maxOffers: this.maxOffers,
512
586
  filling: this.filling
513
587
  };
@@ -535,43 +609,54 @@ export class Rondevu extends EventEmitter {
535
609
  }
536
610
  }
537
611
  /**
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
612
+ * Start polling for remote ICE candidates
613
+ * Returns the polling interval ID
614
+ */
615
+ startIcePolling(pc, serviceFqn, offerId) {
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
542
636
  *
543
637
  * @example
544
638
  * ```typescript
545
639
  * // Connect to specific user
546
640
  * const connection = await rondevu.connectToService({
547
641
  * serviceFqn: 'chat:2.0.0@alice',
548
- * connectionConfig: {
549
- * reconnectEnabled: true,
550
- * bufferEnabled: true
642
+ * onConnection: ({ dc, peerUsername }) => {
643
+ * console.log('Connected to', peerUsername)
644
+ * dc.addEventListener('message', (e) => console.log(e.data))
645
+ * dc.addEventListener('open', () => dc.send('Hello!'))
551
646
  * }
552
647
  * })
553
648
  *
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
- *
567
649
  * // Discover random service
568
650
  * const connection = await rondevu.connectToService({
569
- * service: 'chat:2.0.0'
651
+ * service: 'chat:2.0.0',
652
+ * onConnection: ({ dc, peerUsername }) => {
653
+ * console.log('Connected to', peerUsername)
654
+ * }
570
655
  * })
571
656
  * ```
572
657
  */
573
658
  async connectToService(options) {
574
- const { rtcConfig, connectionConfig } = options;
659
+ const { onConnection, rtcConfig } = options;
575
660
  // Validate inputs
576
661
  if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
577
662
  throw new Error('serviceFqn cannot be empty');
@@ -585,28 +670,73 @@ export class Rondevu extends EventEmitter {
585
670
  // Determine the full service FQN
586
671
  const fqn = await this.resolveServiceFqn(options);
587
672
  this.debug(`Connecting to service: ${fqn}`);
588
- // Get service offer
673
+ // 1. Get service offer
589
674
  const serviceData = await this.api.getService(fqn);
590
675
  this.debug(`Found service from @${serviceData.username}`);
591
- // Create RTCConfiguration
676
+ // 2. Create RTCPeerConnection
592
677
  const rtcConfiguration = rtcConfig || {
593
678
  iceServers: this.iceServers
594
679
  };
595
- // Create AnswererConnection
596
- const connection = new AnswererConnection({
597
- api: this.api,
680
+ const pc = new RTCPeerConnection(rtcConfiguration);
681
+ // 3. Set up data channel handler (answerer receives it from offerer)
682
+ let dc = null;
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,
598
710
  serviceFqn: serviceData.serviceFqn,
599
711
  offerId: serviceData.offerId,
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;
712
+ peerUsername: serviceData.username
713
+ };
714
+ // 9. Set up connection state monitoring
715
+ pc.onconnectionstatechange = () => {
716
+ this.debug(`Connection state: ${pc.connectionState}`);
717
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
718
+ clearInterval(icePollInterval);
719
+ }
720
+ };
721
+ // 10. Wait for data channel to open and call onConnection
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;
610
740
  }
611
741
  // ============================================
612
742
  // Service Discovery
@@ -614,6 +744,8 @@ export class Rondevu extends EventEmitter {
614
744
  /**
615
745
  * Find a service - unified discovery method
616
746
  *
747
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
748
+ *
617
749
  * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
618
750
  * @param options - Discovery options
619
751
  *
@@ -702,34 +834,6 @@ export class Rondevu extends EventEmitter {
702
834
  getPublicKey() {
703
835
  return this.keypair.publicKey;
704
836
  }
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
- }
733
837
  /**
734
838
  * Access to underlying API for advanced operations
735
839
  * @deprecated Use direct methods on Rondevu instance instead
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.18.9",
4
- "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
3
+ "version": "0.18.10",
4
+ "description": "TypeScript client for Rondevu WebRTC signaling with username-based discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",