@xtr-dev/rondevu-client 0.18.1 → 0.18.2

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;
@@ -142,6 +203,7 @@ export declare class Rondevu {
142
203
  isUsernameClaimed(): Promise<boolean>;
143
204
  /**
144
205
  * Default offer factory - creates a simple data channel connection
206
+ * The RTCPeerConnection is created by Rondevu and passed in
145
207
  */
146
208
  private defaultOfferFactory;
147
209
  /**
@@ -160,6 +222,10 @@ export declare class Rondevu {
160
222
  publishService(options: PublishServiceOptions): Promise<void>;
161
223
  /**
162
224
  * Set up ICE candidate handler to send candidates to the server
225
+ *
226
+ * Note: This is used by connectToService() where the offerId is already known.
227
+ * For createOffer(), we use inline ICE handling with early candidate queuing
228
+ * since the offerId isn't available until after the factory completes.
163
229
  */
164
230
  private setupIceCandidateHandler;
165
231
  /**
@@ -184,6 +250,32 @@ export declare class Rondevu {
184
250
  * Closes all active peer connections
185
251
  */
186
252
  stopFilling(): void;
253
+ /**
254
+ * Get the count of active offers
255
+ * @returns Number of active offers
256
+ */
257
+ getOfferCount(): number;
258
+ /**
259
+ * Check if an offer is currently connected
260
+ * @param offerId - The offer ID to check
261
+ * @returns True if the offer exists and has been answered
262
+ */
263
+ isConnected(offerId: string): boolean;
264
+ /**
265
+ * Disconnect all active offers
266
+ * Similar to stopFilling() but doesn't stop the polling/filling process
267
+ */
268
+ disconnectAll(): Promise<void>;
269
+ /**
270
+ * Get the current service status
271
+ * @returns Object with service state information
272
+ */
273
+ getServiceStatus(): {
274
+ active: boolean;
275
+ offerCount: number;
276
+ maxOffers: number;
277
+ filling: boolean;
278
+ };
187
279
  /**
188
280
  * Resolve the full service FQN from various input options
189
281
  * Supports direct FQN, service+username, or service discovery
@@ -221,49 +313,30 @@ export declare class Rondevu {
221
313
  */
222
314
  connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
223
315
  /**
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)
316
+ * Find a service - unified discovery method
317
+ *
318
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
319
+ *
320
+ * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
321
+ * @param options - Discovery options
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * // Direct lookup (has username)
326
+ * const service = await rondevu.findService('chat:1.0.0@alice')
327
+ *
328
+ * // Random discovery (no username)
329
+ * const service = await rondevu.findService('chat:1.0.0')
330
+ *
331
+ * // Paginated discovery
332
+ * const result = await rondevu.findService('chat:1.0.0', {
333
+ * mode: 'paginated',
334
+ * limit: 20,
335
+ * offset: 0
336
+ * })
337
+ * ```
252
338
  */
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
- }>;
339
+ findService(serviceFqn: string, options?: FindServiceOptions): Promise<ServiceResult | PaginatedServiceResult>;
267
340
  /**
268
341
  * Post answer SDP to specific offer
269
342
  */
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;
@@ -224,13 +267,13 @@ export class Rondevu {
224
267
  // ============================================
225
268
  /**
226
269
  * Default offer factory - creates a simple data channel connection
270
+ * The RTCPeerConnection is created by Rondevu and passed in
227
271
  */
228
- async defaultOfferFactory(rtcConfig) {
229
- const pc = new RTCPeerConnection(rtcConfig);
272
+ async defaultOfferFactory(pc) {
230
273
  const dc = pc.createDataChannel('default');
231
274
  const offer = await pc.createOffer();
232
275
  await pc.setLocalDescription(offer);
233
- return { pc, dc, offer };
276
+ return { dc, offer };
234
277
  }
235
278
  /**
236
279
  * Publish a service with automatic offer management
@@ -256,6 +299,10 @@ export class Rondevu {
256
299
  }
257
300
  /**
258
301
  * Set up ICE candidate handler to send candidates to the server
302
+ *
303
+ * Note: This is used by connectToService() where the offerId is already known.
304
+ * For createOffer(), we use inline ICE handling with early candidate queuing
305
+ * since the offerId isn't available until after the factory completes.
259
306
  */
260
307
  setupIceCandidateHandler(pc, serviceFqn, offerId) {
261
308
  pc.onicecandidate = async (event) => {
@@ -267,6 +314,8 @@ export class Rondevu {
267
314
  const candidateData = typeof event.candidate.toJSON === 'function'
268
315
  ? event.candidate.toJSON()
269
316
  : event.candidate;
317
+ // Emit local ICE candidate event
318
+ this.emit('ice:candidate:local', offerId, candidateData);
270
319
  await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
271
320
  }
272
321
  catch (err) {
@@ -285,25 +334,26 @@ export class Rondevu {
285
334
  const rtcConfig = {
286
335
  iceServers: this.iceServers
287
336
  };
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
337
  // Auto-append username to service
293
338
  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
339
+ this.debug('Creating new offer...');
340
+ // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
341
+ const pc = new RTCPeerConnection(rtcConfig);
342
+ // 2. Set up ICE candidate handler with queuing BEFORE the factory runs
343
+ // This ensures we capture all candidates, even those generated immediately
344
+ // when setLocalDescription() is called in the factory
297
345
  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
346
+ let offerId;
301
347
  pc.onicecandidate = async (event) => {
302
348
  if (event.candidate) {
303
349
  // Handle both browser and Node.js (wrtc) environments
304
350
  const candidateData = typeof event.candidate.toJSON === 'function'
305
351
  ? event.candidate.toJSON()
306
352
  : event.candidate;
353
+ // Emit local ICE candidate event
354
+ if (offerId) {
355
+ this.emit('ice:candidate:local', offerId, candidateData);
356
+ }
307
357
  if (offerId) {
308
358
  // We have the offerId, send directly
309
359
  try {
@@ -320,7 +370,22 @@ export class Rondevu {
320
370
  }
321
371
  }
322
372
  };
323
- // Publish to server
373
+ // 3. Call the factory with the pc - factory creates data channel and offer
374
+ // When factory calls setLocalDescription(), ICE gathering starts and
375
+ // candidates are captured by the handler we set up above
376
+ let dc;
377
+ let offer;
378
+ try {
379
+ const factoryResult = await this.offerFactory(pc);
380
+ dc = factoryResult.dc;
381
+ offer = factoryResult.offer;
382
+ }
383
+ catch (err) {
384
+ // Clean up the connection if factory fails
385
+ pc.close();
386
+ throw err;
387
+ }
388
+ // 4. Publish to server to get offerId
324
389
  const result = await this.api.publishService({
325
390
  serviceFqn,
326
391
  offers: [{ sdp: offer.sdp }],
@@ -329,7 +394,7 @@ export class Rondevu {
329
394
  message: '',
330
395
  });
331
396
  offerId = result.offers[0].offerId;
332
- // Store active offer
397
+ // 5. Store active offer
333
398
  this.activeOffers.set(offerId, {
334
399
  offerId,
335
400
  serviceFqn,
@@ -339,7 +404,15 @@ export class Rondevu {
339
404
  createdAt: Date.now()
340
405
  });
341
406
  this.debug(`Offer created: ${offerId}`);
342
- // Send any queued early ICE candidates
407
+ this.emit('offer:created', offerId, serviceFqn);
408
+ // Set up data channel open handler (offerer side)
409
+ if (dc) {
410
+ dc.onopen = () => {
411
+ this.debug(`Data channel opened for offer ${offerId}`);
412
+ this.emit('connection:opened', offerId, dc);
413
+ };
414
+ }
415
+ // 6. Send any queued early ICE candidates
343
416
  if (earlyIceCandidates.length > 0) {
344
417
  this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
345
418
  try {
@@ -349,10 +422,11 @@ export class Rondevu {
349
422
  console.error('[Rondevu] Failed to send early ICE candidates:', err);
350
423
  }
351
424
  }
352
- // Monitor connection state
425
+ // 7. Monitor connection state
353
426
  pc.onconnectionstatechange = () => {
354
427
  this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
355
428
  if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
429
+ this.emit('connection:closed', offerId);
356
430
  this.activeOffers.delete(offerId);
357
431
  this.fillOffers(); // Try to replace failed offer
358
432
  }
@@ -395,6 +469,7 @@ export class Rondevu {
395
469
  });
396
470
  activeOffer.answered = true;
397
471
  this.lastPollTimestamp = answer.answeredAt;
472
+ this.emit('offer:answered', answer.offerId, answer.answererId);
398
473
  // Create replacement offer
399
474
  this.fillOffers();
400
475
  }
@@ -406,6 +481,7 @@ export class Rondevu {
406
481
  const answererCandidates = candidates.filter(c => c.role === 'answerer');
407
482
  for (const item of answererCandidates) {
408
483
  if (item.candidate) {
484
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
409
485
  await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
410
486
  this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
411
487
  }
@@ -458,6 +534,47 @@ export class Rondevu {
458
534
  }
459
535
  this.activeOffers.clear();
460
536
  }
537
+ /**
538
+ * Get the count of active offers
539
+ * @returns Number of active offers
540
+ */
541
+ getOfferCount() {
542
+ return this.activeOffers.size;
543
+ }
544
+ /**
545
+ * Check if an offer is currently connected
546
+ * @param offerId - The offer ID to check
547
+ * @returns True if the offer exists and has been answered
548
+ */
549
+ isConnected(offerId) {
550
+ const offer = this.activeOffers.get(offerId);
551
+ return offer ? offer.answered : false;
552
+ }
553
+ /**
554
+ * Disconnect all active offers
555
+ * Similar to stopFilling() but doesn't stop the polling/filling process
556
+ */
557
+ async disconnectAll() {
558
+ this.debug('Disconnecting all offers');
559
+ for (const [offerId, offer] of this.activeOffers.entries()) {
560
+ this.debug(`Closing offer ${offerId}`);
561
+ offer.dc?.close();
562
+ offer.pc.close();
563
+ }
564
+ this.activeOffers.clear();
565
+ }
566
+ /**
567
+ * Get the current service status
568
+ * @returns Object with service state information
569
+ */
570
+ getServiceStatus() {
571
+ return {
572
+ active: this.currentService !== null,
573
+ offerCount: this.activeOffers.size,
574
+ maxOffers: this.maxOffers,
575
+ filling: this.filling
576
+ };
577
+ }
461
578
  /**
462
579
  * Resolve the full service FQN from various input options
463
580
  * Supports direct FQN, service+username, or service discovery
@@ -473,7 +590,7 @@ export class Rondevu {
473
590
  else if (service) {
474
591
  // Discovery mode - get random service
475
592
  this.debug(`Discovering service: ${service}`);
476
- const discovered = await this.discoverService(service);
593
+ const discovered = await this.findService(service);
477
594
  return discovered.serviceFqn;
478
595
  }
479
596
  else {
@@ -491,6 +608,7 @@ export class Rondevu {
491
608
  const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
492
609
  for (const item of result.candidates) {
493
610
  if (item.candidate) {
611
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
494
612
  await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
495
613
  lastIceTimestamp = item.createdAt;
496
614
  }
@@ -555,6 +673,7 @@ export class Rondevu {
555
673
  pc.ondatachannel = (event) => {
556
674
  this.debug('Data channel received from offerer');
557
675
  dc = event.channel;
676
+ this.emit('connection:opened', serviceData.offerId, dc);
558
677
  resolve(dc);
559
678
  };
560
679
  });
@@ -612,25 +731,41 @@ export class Rondevu {
612
731
  // Service Discovery
613
732
  // ============================================
614
733
  /**
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)
734
+ * Find a service - unified discovery method
735
+ *
736
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
737
+ *
738
+ * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
739
+ * @param options - Discovery options
740
+ *
741
+ * @example
742
+ * ```typescript
743
+ * // Direct lookup (has username)
744
+ * const service = await rondevu.findService('chat:1.0.0@alice')
745
+ *
746
+ * // Random discovery (no username)
747
+ * const service = await rondevu.findService('chat:1.0.0')
748
+ *
749
+ * // Paginated discovery
750
+ * const result = await rondevu.findService('chat:1.0.0', {
751
+ * mode: 'paginated',
752
+ * limit: 20,
753
+ * offset: 0
754
+ * })
755
+ * ```
631
756
  */
632
- async discoverServices(serviceVersion, limit = 10, offset = 0) {
633
- return await this.api.getService(serviceVersion, { limit, offset });
757
+ async findService(serviceFqn, options) {
758
+ const { mode, limit = 10, offset = 0 } = options || {};
759
+ // Auto-detect mode if not specified
760
+ const hasUsername = serviceFqn.includes('@');
761
+ const effectiveMode = mode || (hasUsername ? 'direct' : 'random');
762
+ if (effectiveMode === 'paginated') {
763
+ return await this.api.getService(serviceFqn, { limit, offset });
764
+ }
765
+ else {
766
+ // Both 'direct' and 'random' use the same API call
767
+ return await this.api.getService(serviceFqn);
768
+ }
634
769
  }
635
770
  // ============================================
636
771
  // 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.2",
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",