@xtr-dev/rondevu-client 0.18.1 → 0.18.3

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/README.md CHANGED
@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
49
49
  await rondevu.publishService({
50
50
  service: 'chat:1.0.0',
51
51
  maxOffers: 5, // Maintain up to 5 concurrent offers
52
- offerFactory: async (rtcConfig) => {
53
- const pc = new RTCPeerConnection(rtcConfig)
52
+ offerFactory: async (pc) => {
53
+ // pc is created by Rondevu with ICE handlers already attached
54
54
  const dc = pc.createDataChannel('chat')
55
55
 
56
56
  dc.addEventListener('open', () => {
@@ -64,7 +64,7 @@ await rondevu.publishService({
64
64
 
65
65
  const offer = await pc.createOffer()
66
66
  await pc.setLocalDescription(offer)
67
- return { pc, dc, offer }
67
+ return { dc, offer }
68
68
  }
69
69
  })
70
70
 
package/dist/api.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface Service {
31
31
  }
32
32
  export interface IceCandidate {
33
33
  candidate: RTCIceCandidateInit | null;
34
+ role: 'offerer' | 'answerer';
34
35
  createdAt: number;
35
36
  }
36
37
  /**
package/dist/index.d.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  * @xtr-dev/rondevu-client
3
3
  * WebRTC peer signaling client
4
4
  */
5
- export { Rondevu } from './rondevu.js';
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
8
  export { WebCryptoAdapter } from './web-crypto-adapter.js';
9
9
  export { NodeCryptoAdapter } from './node-crypto-adapter.js';
10
10
  export type { Signaler, Binnable, } from './types.js';
11
11
  export type { Keypair, OfferRequest, ServiceRequest, Service, ServiceOffer, IceCandidate, } from './api.js';
12
- export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory } from './rondevu.js';
12
+ export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory, ActiveOffer, FindServiceOptions, ServiceResult, PaginatedServiceResult } from './rondevu.js';
13
13
  export type { CryptoAdapter } from './crypto-adapter.js';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @xtr-dev/rondevu-client
3
3
  * WebRTC peer signaling client
4
4
  */
5
- export { Rondevu } from './rondevu.js';
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
8
  // Export crypto adapters
@@ -65,12 +65,10 @@ export class NodeCryptoAdapter {
65
65
  }
66
66
  bytesToBase64(bytes) {
67
67
  // Node.js Buffer provides native base64 encoding
68
- // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
69
68
  return Buffer.from(bytes).toString('base64');
70
69
  }
71
70
  base64ToBytes(base64) {
72
71
  // Node.js Buffer provides native base64 decoding
73
- // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
74
72
  return new Uint8Array(Buffer.from(base64, 'base64'));
75
73
  }
76
74
  randomBytes(length) {
package/dist/rondevu.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js';
2
2
  import { CryptoAdapter } from './crypto-adapter.js';
3
+ import { EventEmitter } from 'events';
3
4
  export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only';
4
5
  export declare const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]>;
5
6
  export interface RondevuOptions {
@@ -14,11 +15,18 @@ export interface RondevuOptions {
14
15
  rtcIceCandidate?: typeof RTCIceCandidate;
15
16
  }
16
17
  export interface OfferContext {
17
- pc: RTCPeerConnection;
18
18
  dc?: RTCDataChannel;
19
19
  offer: RTCSessionDescriptionInit;
20
20
  }
21
- export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>;
21
+ /**
22
+ * Factory function for creating WebRTC offers.
23
+ * Rondevu creates the RTCPeerConnection and passes it to the factory,
24
+ * allowing ICE candidate handlers to be set up before setLocalDescription() is called.
25
+ *
26
+ * @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers)
27
+ * @returns Promise containing the data channel (optional) and offer SDP
28
+ */
29
+ export type OfferFactory = (pc: RTCPeerConnection) => Promise<OfferContext>;
22
30
  export interface PublishServiceOptions {
23
31
  service: string;
24
32
  maxOffers: number;
@@ -39,6 +47,59 @@ export interface ConnectToServiceOptions {
39
47
  onConnection?: (context: ConnectionContext) => void | Promise<void>;
40
48
  rtcConfig?: RTCConfiguration;
41
49
  }
50
+ export interface ActiveOffer {
51
+ offerId: string;
52
+ serviceFqn: string;
53
+ pc: RTCPeerConnection;
54
+ dc?: RTCDataChannel;
55
+ answered: boolean;
56
+ createdAt: number;
57
+ }
58
+ export interface FindServiceOptions {
59
+ mode?: 'direct' | 'random' | 'paginated';
60
+ limit?: number;
61
+ offset?: number;
62
+ }
63
+ export interface ServiceResult {
64
+ serviceId: string;
65
+ username: string;
66
+ serviceFqn: string;
67
+ offerId: string;
68
+ sdp: string;
69
+ createdAt: number;
70
+ expiresAt: number;
71
+ }
72
+ export interface PaginatedServiceResult {
73
+ services: ServiceResult[];
74
+ count: number;
75
+ limit: number;
76
+ offset: number;
77
+ }
78
+ /**
79
+ * Base error class for Rondevu errors
80
+ */
81
+ export declare class RondevuError extends Error {
82
+ context?: Record<string, any> | undefined;
83
+ constructor(message: string, context?: Record<string, any> | undefined);
84
+ }
85
+ /**
86
+ * Network-related errors (API calls, connectivity)
87
+ */
88
+ export declare class NetworkError extends RondevuError {
89
+ constructor(message: string, context?: Record<string, any>);
90
+ }
91
+ /**
92
+ * Validation errors (invalid input, malformed data)
93
+ */
94
+ export declare class ValidationError extends RondevuError {
95
+ constructor(message: string, context?: Record<string, any>);
96
+ }
97
+ /**
98
+ * WebRTC connection errors (peer connection failures, ICE issues)
99
+ */
100
+ export declare class ConnectionError extends RondevuError {
101
+ constructor(message: string, context?: Record<string, any>);
102
+ }
42
103
  /**
43
104
  * Rondevu - Complete WebRTC signaling client
44
105
  *
@@ -72,12 +133,12 @@ export interface ConnectToServiceOptions {
72
133
  * await rondevu.publishService({
73
134
  * service: 'chat:2.0.0',
74
135
  * maxOffers: 5, // Maintain up to 5 concurrent offers
75
- * offerFactory: async (rtcConfig) => {
76
- * const pc = new RTCPeerConnection(rtcConfig)
136
+ * offerFactory: async (pc) => {
137
+ * // pc is created by Rondevu with ICE handlers already attached
77
138
  * const dc = pc.createDataChannel('chat')
78
139
  * const offer = await pc.createOffer()
79
140
  * await pc.setLocalDescription(offer)
80
- * return { pc, dc, offer }
141
+ * return { dc, offer }
81
142
  * }
82
143
  * })
83
144
  *
@@ -93,7 +154,7 @@ export interface ConnectToServiceOptions {
93
154
  * rondevu.stopFilling()
94
155
  * ```
95
156
  */
96
- export declare class Rondevu {
157
+ export declare class Rondevu extends EventEmitter {
97
158
  private static readonly DEFAULT_TTL_MS;
98
159
  private static readonly POLLING_INTERVAL_MS;
99
160
  private api;
@@ -115,6 +176,7 @@ export declare class Rondevu {
115
176
  private filling;
116
177
  private pollingInterval;
117
178
  private lastPollTimestamp;
179
+ private isPolling;
118
180
  private constructor();
119
181
  /**
120
182
  * Internal debug logging - only logs if debug mode is enabled
@@ -142,6 +204,7 @@ export declare class Rondevu {
142
204
  isUsernameClaimed(): Promise<boolean>;
143
205
  /**
144
206
  * Default offer factory - creates a simple data channel connection
207
+ * The RTCPeerConnection is created by Rondevu and passed in
145
208
  */
146
209
  private defaultOfferFactory;
147
210
  /**
@@ -160,6 +223,10 @@ export declare class Rondevu {
160
223
  publishService(options: PublishServiceOptions): Promise<void>;
161
224
  /**
162
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.
163
230
  */
164
231
  private setupIceCandidateHandler;
165
232
  /**
@@ -184,6 +251,32 @@ export declare class Rondevu {
184
251
  * Closes all active peer connections
185
252
  */
186
253
  stopFilling(): void;
254
+ /**
255
+ * Get the count of active offers
256
+ * @returns Number of active offers
257
+ */
258
+ getOfferCount(): number;
259
+ /**
260
+ * Check if an offer is currently connected
261
+ * @param offerId - The offer ID to check
262
+ * @returns True if the offer exists and has been answered
263
+ */
264
+ isConnected(offerId: string): boolean;
265
+ /**
266
+ * Disconnect all active offers
267
+ * Similar to stopFilling() but doesn't stop the polling/filling process
268
+ */
269
+ disconnectAll(): Promise<void>;
270
+ /**
271
+ * Get the current service status
272
+ * @returns Object with service state information
273
+ */
274
+ getServiceStatus(): {
275
+ active: boolean;
276
+ offerCount: number;
277
+ maxOffers: number;
278
+ filling: boolean;
279
+ };
187
280
  /**
188
281
  * Resolve the full service FQN from various input options
189
282
  * Supports direct FQN, service+username, or service discovery
@@ -221,49 +314,30 @@ export declare class Rondevu {
221
314
  */
222
315
  connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
223
316
  /**
224
- * Get service by FQN (with username) - Direct lookup
225
- * Example: chat:1.0.0@alice
226
- */
227
- getService(serviceFqn: string): Promise<{
228
- serviceId: string;
229
- username: string;
230
- serviceFqn: string;
231
- offerId: string;
232
- sdp: string;
233
- createdAt: number;
234
- expiresAt: number;
235
- }>;
236
- /**
237
- * Discover a random available service without knowing the username
238
- * Example: chat:1.0.0 (without @username)
239
- */
240
- discoverService(serviceVersion: string): Promise<{
241
- serviceId: string;
242
- username: string;
243
- serviceFqn: string;
244
- offerId: string;
245
- sdp: string;
246
- createdAt: number;
247
- expiresAt: number;
248
- }>;
249
- /**
250
- * Discover multiple available services with pagination
251
- * Example: chat:1.0.0 (without @username)
317
+ * Find a service - unified discovery method
318
+ *
319
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
320
+ *
321
+ * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
322
+ * @param options - Discovery options
323
+ *
324
+ * @example
325
+ * ```typescript
326
+ * // Direct lookup (has username)
327
+ * const service = await rondevu.findService('chat:1.0.0@alice')
328
+ *
329
+ * // Random discovery (no username)
330
+ * const service = await rondevu.findService('chat:1.0.0')
331
+ *
332
+ * // Paginated discovery
333
+ * const result = await rondevu.findService('chat:1.0.0', {
334
+ * mode: 'paginated',
335
+ * limit: 20,
336
+ * offset: 0
337
+ * })
338
+ * ```
252
339
  */
253
- discoverServices(serviceVersion: string, limit?: number, offset?: number): Promise<{
254
- services: Array<{
255
- serviceId: string;
256
- username: string;
257
- serviceFqn: string;
258
- offerId: string;
259
- sdp: string;
260
- createdAt: number;
261
- expiresAt: number;
262
- }>;
263
- count: number;
264
- limit: number;
265
- offset: number;
266
- }>;
340
+ findService(serviceFqn: string, options?: FindServiceOptions): Promise<ServiceResult | PaginatedServiceResult>;
267
341
  /**
268
342
  * Post answer SDP to specific offer
269
343
  */
package/dist/rondevu.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { RondevuAPI } from './api.js';
2
+ import { EventEmitter } from 'events';
2
3
  // ICE server presets
3
4
  export const ICE_SERVER_PRESETS = {
4
5
  'ipv4-turn': [
@@ -43,6 +44,47 @@ export const ICE_SERVER_PRESETS = {
43
44
  }
44
45
  ]
45
46
  };
47
+ /**
48
+ * Base error class for Rondevu errors
49
+ */
50
+ export class RondevuError extends Error {
51
+ constructor(message, context) {
52
+ super(message);
53
+ this.context = context;
54
+ this.name = 'RondevuError';
55
+ Object.setPrototypeOf(this, RondevuError.prototype);
56
+ }
57
+ }
58
+ /**
59
+ * Network-related errors (API calls, connectivity)
60
+ */
61
+ export class NetworkError extends RondevuError {
62
+ constructor(message, context) {
63
+ super(message, context);
64
+ this.name = 'NetworkError';
65
+ Object.setPrototypeOf(this, NetworkError.prototype);
66
+ }
67
+ }
68
+ /**
69
+ * Validation errors (invalid input, malformed data)
70
+ */
71
+ export class ValidationError extends RondevuError {
72
+ constructor(message, context) {
73
+ super(message, context);
74
+ this.name = 'ValidationError';
75
+ Object.setPrototypeOf(this, ValidationError.prototype);
76
+ }
77
+ }
78
+ /**
79
+ * WebRTC connection errors (peer connection failures, ICE issues)
80
+ */
81
+ export class ConnectionError extends RondevuError {
82
+ constructor(message, context) {
83
+ super(message, context);
84
+ this.name = 'ConnectionError';
85
+ Object.setPrototypeOf(this, ConnectionError.prototype);
86
+ }
87
+ }
46
88
  /**
47
89
  * Rondevu - Complete WebRTC signaling client
48
90
  *
@@ -76,12 +118,12 @@ export const ICE_SERVER_PRESETS = {
76
118
  * await rondevu.publishService({
77
119
  * service: 'chat:2.0.0',
78
120
  * maxOffers: 5, // Maintain up to 5 concurrent offers
79
- * offerFactory: async (rtcConfig) => {
80
- * const pc = new RTCPeerConnection(rtcConfig)
121
+ * offerFactory: async (pc) => {
122
+ * // pc is created by Rondevu with ICE handlers already attached
81
123
  * const dc = pc.createDataChannel('chat')
82
124
  * const offer = await pc.createOffer()
83
125
  * await pc.setLocalDescription(offer)
84
- * return { pc, dc, offer }
126
+ * return { dc, offer }
85
127
  * }
86
128
  * })
87
129
  *
@@ -97,8 +139,9 @@ export const ICE_SERVER_PRESETS = {
97
139
  * rondevu.stopFilling()
98
140
  * ```
99
141
  */
100
- export class Rondevu {
142
+ export class Rondevu extends EventEmitter {
101
143
  constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false, rtcPeerConnection, rtcIceCandidate) {
144
+ super();
102
145
  this.usernameClaimed = false;
103
146
  // Service management
104
147
  this.currentService = null;
@@ -110,6 +153,7 @@ export class Rondevu {
110
153
  this.filling = false;
111
154
  this.pollingInterval = null;
112
155
  this.lastPollTimestamp = 0;
156
+ this.isPolling = false; // Guard against concurrent poll execution
113
157
  this.apiUrl = apiUrl;
114
158
  this.username = username;
115
159
  this.keypair = keypair;
@@ -224,13 +268,13 @@ export class Rondevu {
224
268
  // ============================================
225
269
  /**
226
270
  * Default offer factory - creates a simple data channel connection
271
+ * The RTCPeerConnection is created by Rondevu and passed in
227
272
  */
228
- async defaultOfferFactory(rtcConfig) {
229
- const pc = new RTCPeerConnection(rtcConfig);
273
+ async defaultOfferFactory(pc) {
230
274
  const dc = pc.createDataChannel('default');
231
275
  const offer = await pc.createOffer();
232
276
  await pc.setLocalDescription(offer);
233
- return { pc, dc, offer };
277
+ return { dc, offer };
234
278
  }
235
279
  /**
236
280
  * Publish a service with automatic offer management
@@ -256,6 +300,10 @@ export class Rondevu {
256
300
  }
257
301
  /**
258
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.
259
307
  */
260
308
  setupIceCandidateHandler(pc, serviceFqn, offerId) {
261
309
  pc.onicecandidate = async (event) => {
@@ -267,6 +315,8 @@ export class Rondevu {
267
315
  const candidateData = typeof event.candidate.toJSON === 'function'
268
316
  ? event.candidate.toJSON()
269
317
  : event.candidate;
318
+ // Emit local ICE candidate event
319
+ this.emit('ice:candidate:local', offerId, candidateData);
270
320
  await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
271
321
  }
272
322
  catch (err) {
@@ -285,25 +335,26 @@ export class Rondevu {
285
335
  const rtcConfig = {
286
336
  iceServers: this.iceServers
287
337
  };
288
- this.debug('Creating new offer...');
289
- // Create the offer using the factory
290
- // Note: The factory may call setLocalDescription() which triggers ICE gathering
291
- const { pc, dc, offer } = await this.offerFactory(rtcConfig);
292
338
  // Auto-append username to service
293
339
  const serviceFqn = `${this.currentService}@${this.username}`;
294
- // Queue to buffer ICE candidates generated before we have the offerId
295
- // This fixes the race condition where ICE candidates are lost because
296
- // they're generated before we can set up the handler with the offerId
340
+ this.debug('Creating new offer...');
341
+ // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
342
+ 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
297
346
  const earlyIceCandidates = [];
298
- let offerId = null;
299
- // Set up a queuing ICE candidate handler immediately after getting the pc
300
- // This captures any candidates that fire before we have the offerId
347
+ let offerId;
301
348
  pc.onicecandidate = async (event) => {
302
349
  if (event.candidate) {
303
350
  // Handle both browser and Node.js (wrtc) environments
304
351
  const candidateData = typeof event.candidate.toJSON === 'function'
305
352
  ? event.candidate.toJSON()
306
353
  : event.candidate;
354
+ // Emit local ICE candidate event
355
+ if (offerId) {
356
+ this.emit('ice:candidate:local', offerId, candidateData);
357
+ }
307
358
  if (offerId) {
308
359
  // We have the offerId, send directly
309
360
  try {
@@ -320,7 +371,22 @@ export class Rondevu {
320
371
  }
321
372
  }
322
373
  };
323
- // Publish to server
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
377
+ let dc;
378
+ let offer;
379
+ try {
380
+ const factoryResult = await this.offerFactory(pc);
381
+ dc = factoryResult.dc;
382
+ offer = factoryResult.offer;
383
+ }
384
+ catch (err) {
385
+ // Clean up the connection if factory fails
386
+ pc.close();
387
+ throw err;
388
+ }
389
+ // 4. Publish to server to get offerId
324
390
  const result = await this.api.publishService({
325
391
  serviceFqn,
326
392
  offers: [{ sdp: offer.sdp }],
@@ -329,7 +395,7 @@ export class Rondevu {
329
395
  message: '',
330
396
  });
331
397
  offerId = result.offers[0].offerId;
332
- // Store active offer
398
+ // 5. Store active offer
333
399
  this.activeOffers.set(offerId, {
334
400
  offerId,
335
401
  serviceFqn,
@@ -339,7 +405,15 @@ export class Rondevu {
339
405
  createdAt: Date.now()
340
406
  });
341
407
  this.debug(`Offer created: ${offerId}`);
342
- // Send any queued early ICE candidates
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
343
417
  if (earlyIceCandidates.length > 0) {
344
418
  this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
345
419
  try {
@@ -349,10 +423,11 @@ export class Rondevu {
349
423
  console.error('[Rondevu] Failed to send early ICE candidates:', err);
350
424
  }
351
425
  }
352
- // Monitor connection state
426
+ // 7. Monitor connection state
353
427
  pc.onconnectionstatechange = () => {
354
428
  this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
355
429
  if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
430
+ this.emit('connection:closed', offerId);
356
431
  this.activeOffers.delete(offerId);
357
432
  this.fillOffers(); // Try to replace failed offer
358
433
  }
@@ -382,6 +457,12 @@ export class Rondevu {
382
457
  async pollInternal() {
383
458
  if (!this.filling)
384
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;
385
466
  try {
386
467
  const result = await this.api.poll(this.lastPollTimestamp);
387
468
  // Process answers
@@ -395,6 +476,7 @@ export class Rondevu {
395
476
  });
396
477
  activeOffer.answered = true;
397
478
  this.lastPollTimestamp = answer.answeredAt;
479
+ this.emit('offer:answered', answer.offerId, answer.answererId);
398
480
  // Create replacement offer
399
481
  this.fillOffers();
400
482
  }
@@ -406,6 +488,7 @@ export class Rondevu {
406
488
  const answererCandidates = candidates.filter(c => c.role === 'answerer');
407
489
  for (const item of answererCandidates) {
408
490
  if (item.candidate) {
491
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
409
492
  await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
410
493
  this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
411
494
  }
@@ -416,6 +499,9 @@ export class Rondevu {
416
499
  catch (err) {
417
500
  console.error('[Rondevu] Polling error:', err);
418
501
  }
502
+ finally {
503
+ this.isPolling = false;
504
+ }
419
505
  }
420
506
  /**
421
507
  * Start filling offers and polling for answers/ICE
@@ -445,6 +531,7 @@ export class Rondevu {
445
531
  stopFilling() {
446
532
  this.debug('Stopping offer filling and polling');
447
533
  this.filling = false;
534
+ this.isPolling = false; // Reset polling guard
448
535
  // Stop polling
449
536
  if (this.pollingInterval) {
450
537
  clearInterval(this.pollingInterval);
@@ -458,6 +545,47 @@ export class Rondevu {
458
545
  }
459
546
  this.activeOffers.clear();
460
547
  }
548
+ /**
549
+ * Get the count of active offers
550
+ * @returns Number of active offers
551
+ */
552
+ getOfferCount() {
553
+ return this.activeOffers.size;
554
+ }
555
+ /**
556
+ * Check if an offer is currently connected
557
+ * @param offerId - The offer ID to check
558
+ * @returns True if the offer exists and has been answered
559
+ */
560
+ isConnected(offerId) {
561
+ const offer = this.activeOffers.get(offerId);
562
+ return offer ? offer.answered : false;
563
+ }
564
+ /**
565
+ * Disconnect all active offers
566
+ * Similar to stopFilling() but doesn't stop the polling/filling process
567
+ */
568
+ async disconnectAll() {
569
+ this.debug('Disconnecting all offers');
570
+ for (const [offerId, offer] of this.activeOffers.entries()) {
571
+ this.debug(`Closing offer ${offerId}`);
572
+ offer.dc?.close();
573
+ offer.pc.close();
574
+ }
575
+ this.activeOffers.clear();
576
+ }
577
+ /**
578
+ * Get the current service status
579
+ * @returns Object with service state information
580
+ */
581
+ getServiceStatus() {
582
+ return {
583
+ active: this.currentService !== null,
584
+ offerCount: this.activeOffers.size,
585
+ maxOffers: this.maxOffers,
586
+ filling: this.filling
587
+ };
588
+ }
461
589
  /**
462
590
  * Resolve the full service FQN from various input options
463
591
  * Supports direct FQN, service+username, or service discovery
@@ -473,7 +601,7 @@ export class Rondevu {
473
601
  else if (service) {
474
602
  // Discovery mode - get random service
475
603
  this.debug(`Discovering service: ${service}`);
476
- const discovered = await this.discoverService(service);
604
+ const discovered = await this.findService(service);
477
605
  return discovered.serviceFqn;
478
606
  }
479
607
  else {
@@ -491,6 +619,7 @@ export class Rondevu {
491
619
  const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
492
620
  for (const item of result.candidates) {
493
621
  if (item.candidate) {
622
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
494
623
  await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
495
624
  lastIceTimestamp = item.createdAt;
496
625
  }
@@ -555,6 +684,7 @@ export class Rondevu {
555
684
  pc.ondatachannel = (event) => {
556
685
  this.debug('Data channel received from offerer');
557
686
  dc = event.channel;
687
+ this.emit('connection:opened', serviceData.offerId, dc);
558
688
  resolve(dc);
559
689
  };
560
690
  });
@@ -612,25 +742,41 @@ export class Rondevu {
612
742
  // Service Discovery
613
743
  // ============================================
614
744
  /**
615
- * Get service by FQN (with username) - Direct lookup
616
- * Example: chat:1.0.0@alice
617
- */
618
- async getService(serviceFqn) {
619
- return await this.api.getService(serviceFqn);
620
- }
621
- /**
622
- * Discover a random available service without knowing the username
623
- * Example: chat:1.0.0 (without @username)
624
- */
625
- async discoverService(serviceVersion) {
626
- return await this.api.getService(serviceVersion);
627
- }
628
- /**
629
- * Discover multiple available services with pagination
630
- * Example: chat:1.0.0 (without @username)
745
+ * Find a service - unified discovery method
746
+ *
747
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
748
+ *
749
+ * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
750
+ * @param options - Discovery options
751
+ *
752
+ * @example
753
+ * ```typescript
754
+ * // Direct lookup (has username)
755
+ * const service = await rondevu.findService('chat:1.0.0@alice')
756
+ *
757
+ * // Random discovery (no username)
758
+ * const service = await rondevu.findService('chat:1.0.0')
759
+ *
760
+ * // Paginated discovery
761
+ * const result = await rondevu.findService('chat:1.0.0', {
762
+ * mode: 'paginated',
763
+ * limit: 20,
764
+ * offset: 0
765
+ * })
766
+ * ```
631
767
  */
632
- async discoverServices(serviceVersion, limit = 10, offset = 0) {
633
- return await this.api.getService(serviceVersion, { limit, offset });
768
+ async findService(serviceFqn, options) {
769
+ const { mode, limit = 10, offset = 0 } = options || {};
770
+ // Auto-detect mode if not specified
771
+ const hasUsername = serviceFqn.includes('@');
772
+ const effectiveMode = mode || (hasUsername ? 'direct' : 'random');
773
+ if (effectiveMode === 'paginated') {
774
+ return await this.api.getService(serviceFqn, { limit, offset });
775
+ }
776
+ else {
777
+ // Both 'direct' and 'random' use the same API call
778
+ return await this.api.getService(serviceFqn);
779
+ }
634
780
  }
635
781
  // ============================================
636
782
  // WebRTC Signaling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,6 +25,7 @@
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
27
  "@eslint/js": "^9.39.1",
28
+ "@types/node": "^25.0.2",
28
29
  "@typescript-eslint/eslint-plugin": "^8.48.1",
29
30
  "@typescript-eslint/parser": "^8.48.1",
30
31
  "eslint": "^9.39.1",