@xtr-dev/rondevu-client 0.17.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 {
@@ -10,13 +11,22 @@ export interface RondevuOptions {
10
11
  batching?: BatcherOptions | false;
11
12
  iceServers?: IceServerPreset | RTCIceServer[];
12
13
  debug?: boolean;
14
+ rtcPeerConnection?: typeof RTCPeerConnection;
15
+ rtcIceCandidate?: typeof RTCIceCandidate;
13
16
  }
14
17
  export interface OfferContext {
15
- pc: RTCPeerConnection;
16
18
  dc?: RTCDataChannel;
17
19
  offer: RTCSessionDescriptionInit;
18
20
  }
19
- 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>;
20
30
  export interface PublishServiceOptions {
21
31
  service: string;
22
32
  maxOffers: number;
@@ -37,6 +47,59 @@ export interface ConnectToServiceOptions {
37
47
  onConnection?: (context: ConnectionContext) => void | Promise<void>;
38
48
  rtcConfig?: RTCConfiguration;
39
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
+ }
40
103
  /**
41
104
  * Rondevu - Complete WebRTC signaling client
42
105
  *
@@ -70,12 +133,12 @@ export interface ConnectToServiceOptions {
70
133
  * await rondevu.publishService({
71
134
  * service: 'chat:2.0.0',
72
135
  * maxOffers: 5, // Maintain up to 5 concurrent offers
73
- * offerFactory: async (rtcConfig) => {
74
- * const pc = new RTCPeerConnection(rtcConfig)
136
+ * offerFactory: async (pc) => {
137
+ * // pc is created by Rondevu with ICE handlers already attached
75
138
  * const dc = pc.createDataChannel('chat')
76
139
  * const offer = await pc.createOffer()
77
140
  * await pc.setLocalDescription(offer)
78
- * return { pc, dc, offer }
141
+ * return { dc, offer }
79
142
  * }
80
143
  * })
81
144
  *
@@ -91,7 +154,7 @@ export interface ConnectToServiceOptions {
91
154
  * rondevu.stopFilling()
92
155
  * ```
93
156
  */
94
- export declare class Rondevu {
157
+ export declare class Rondevu extends EventEmitter {
95
158
  private static readonly DEFAULT_TTL_MS;
96
159
  private static readonly POLLING_INTERVAL_MS;
97
160
  private api;
@@ -103,6 +166,8 @@ export declare class Rondevu {
103
166
  private batchingOptions?;
104
167
  private iceServers;
105
168
  private debugEnabled;
169
+ private rtcPeerConnection?;
170
+ private rtcIceCandidate?;
106
171
  private currentService;
107
172
  private maxOffers;
108
173
  private offerFactory;
@@ -138,6 +203,7 @@ export declare class Rondevu {
138
203
  isUsernameClaimed(): Promise<boolean>;
139
204
  /**
140
205
  * Default offer factory - creates a simple data channel connection
206
+ * The RTCPeerConnection is created by Rondevu and passed in
141
207
  */
142
208
  private defaultOfferFactory;
143
209
  /**
@@ -156,6 +222,10 @@ export declare class Rondevu {
156
222
  publishService(options: PublishServiceOptions): Promise<void>;
157
223
  /**
158
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.
159
229
  */
160
230
  private setupIceCandidateHandler;
161
231
  /**
@@ -180,6 +250,32 @@ export declare class Rondevu {
180
250
  * Closes all active peer connections
181
251
  */
182
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
+ };
183
279
  /**
184
280
  * Resolve the full service FQN from various input options
185
281
  * Supports direct FQN, service+username, or service discovery
@@ -217,49 +313,30 @@ export declare class Rondevu {
217
313
  */
218
314
  connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
219
315
  /**
220
- * Get service by FQN (with username) - Direct lookup
221
- * Example: chat:1.0.0@alice
222
- */
223
- getService(serviceFqn: string): Promise<{
224
- serviceId: string;
225
- username: string;
226
- serviceFqn: string;
227
- offerId: string;
228
- sdp: string;
229
- createdAt: number;
230
- expiresAt: number;
231
- }>;
232
- /**
233
- * Discover a random available service without knowing the username
234
- * Example: chat:1.0.0 (without @username)
235
- */
236
- discoverService(serviceVersion: string): Promise<{
237
- serviceId: string;
238
- username: string;
239
- serviceFqn: string;
240
- offerId: string;
241
- sdp: string;
242
- createdAt: number;
243
- expiresAt: number;
244
- }>;
245
- /**
246
- * Discover multiple available services with pagination
247
- * 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
+ * ```
248
338
  */
249
- discoverServices(serviceVersion: string, limit?: number, offset?: number): Promise<{
250
- services: Array<{
251
- serviceId: string;
252
- username: string;
253
- serviceFqn: string;
254
- offerId: string;
255
- sdp: string;
256
- createdAt: number;
257
- expiresAt: number;
258
- }>;
259
- count: number;
260
- limit: number;
261
- offset: number;
262
- }>;
339
+ findService(serviceFqn: string, options?: FindServiceOptions): Promise<ServiceResult | PaginatedServiceResult>;
263
340
  /**
264
341
  * Post answer SDP to specific offer
265
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 {
101
- constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false) {
142
+ export class Rondevu extends EventEmitter {
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;
@@ -118,6 +161,8 @@ export class Rondevu {
118
161
  this.cryptoAdapter = cryptoAdapter;
119
162
  this.batchingOptions = batchingOptions;
120
163
  this.debugEnabled = debugEnabled;
164
+ this.rtcPeerConnection = rtcPeerConnection;
165
+ this.rtcIceCandidate = rtcIceCandidate;
121
166
  this.debug('Instance created:', {
122
167
  username: this.username,
123
168
  publicKey: this.keypair.publicKey,
@@ -146,6 +191,13 @@ export class Rondevu {
146
191
  */
147
192
  static async connect(options) {
148
193
  const username = options.username || Rondevu.generateAnonymousUsername();
194
+ // Apply WebRTC polyfills to global scope if provided (Node.js environments)
195
+ if (options.rtcPeerConnection) {
196
+ globalThis.RTCPeerConnection = options.rtcPeerConnection;
197
+ }
198
+ if (options.rtcIceCandidate) {
199
+ globalThis.RTCIceCandidate = options.rtcIceCandidate;
200
+ }
149
201
  // Handle preset string or custom array
150
202
  let iceServers;
151
203
  if (typeof options.iceServers === 'string') {
@@ -181,7 +233,7 @@ export class Rondevu {
181
233
  const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
182
234
  if (options.debug)
183
235
  console.log('[Rondevu] Created API instance');
184
- return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false);
236
+ return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false, options.rtcPeerConnection, options.rtcIceCandidate);
185
237
  }
186
238
  /**
187
239
  * Generate an anonymous username with timestamp and random component
@@ -215,13 +267,13 @@ export class Rondevu {
215
267
  // ============================================
216
268
  /**
217
269
  * Default offer factory - creates a simple data channel connection
270
+ * The RTCPeerConnection is created by Rondevu and passed in
218
271
  */
219
- async defaultOfferFactory(rtcConfig) {
220
- const pc = new RTCPeerConnection(rtcConfig);
272
+ async defaultOfferFactory(pc) {
221
273
  const dc = pc.createDataChannel('default');
222
274
  const offer = await pc.createOffer();
223
275
  await pc.setLocalDescription(offer);
224
- return { pc, dc, offer };
276
+ return { dc, offer };
225
277
  }
226
278
  /**
227
279
  * Publish a service with automatic offer management
@@ -247,6 +299,10 @@ export class Rondevu {
247
299
  }
248
300
  /**
249
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.
250
306
  */
251
307
  setupIceCandidateHandler(pc, serviceFqn, offerId) {
252
308
  pc.onicecandidate = async (event) => {
@@ -258,6 +314,8 @@ export class Rondevu {
258
314
  const candidateData = typeof event.candidate.toJSON === 'function'
259
315
  ? event.candidate.toJSON()
260
316
  : event.candidate;
317
+ // Emit local ICE candidate event
318
+ this.emit('ice:candidate:local', offerId, candidateData);
261
319
  await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
262
320
  }
263
321
  catch (err) {
@@ -276,12 +334,58 @@ export class Rondevu {
276
334
  const rtcConfig = {
277
335
  iceServers: this.iceServers
278
336
  };
279
- this.debug('Creating new offer...');
280
- // Create the offer using the factory
281
- const { pc, dc, offer } = await this.offerFactory(rtcConfig);
282
337
  // Auto-append username to service
283
338
  const serviceFqn = `${this.currentService}@${this.username}`;
284
- // Publish to server
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
345
+ const earlyIceCandidates = [];
346
+ let offerId;
347
+ pc.onicecandidate = async (event) => {
348
+ if (event.candidate) {
349
+ // Handle both browser and Node.js (wrtc) environments
350
+ const candidateData = typeof event.candidate.toJSON === 'function'
351
+ ? event.candidate.toJSON()
352
+ : event.candidate;
353
+ // Emit local ICE candidate event
354
+ if (offerId) {
355
+ this.emit('ice:candidate:local', offerId, candidateData);
356
+ }
357
+ if (offerId) {
358
+ // We have the offerId, send directly
359
+ try {
360
+ await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
361
+ }
362
+ catch (err) {
363
+ console.error('[Rondevu] Failed to send ICE candidate:', err);
364
+ }
365
+ }
366
+ else {
367
+ // Queue for later - we don't have the offerId yet
368
+ this.debug('Queuing early ICE candidate');
369
+ earlyIceCandidates.push(candidateData);
370
+ }
371
+ }
372
+ };
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
285
389
  const result = await this.api.publishService({
286
390
  serviceFqn,
287
391
  offers: [{ sdp: offer.sdp }],
@@ -289,8 +393,8 @@ export class Rondevu {
289
393
  signature: '',
290
394
  message: '',
291
395
  });
292
- const offerId = result.offers[0].offerId;
293
- // Store active offer
396
+ offerId = result.offers[0].offerId;
397
+ // 5. Store active offer
294
398
  this.activeOffers.set(offerId, {
295
399
  offerId,
296
400
  serviceFqn,
@@ -300,12 +404,29 @@ export class Rondevu {
300
404
  createdAt: Date.now()
301
405
  });
302
406
  this.debug(`Offer created: ${offerId}`);
303
- // Set up ICE candidate handler
304
- this.setupIceCandidateHandler(pc, serviceFqn, offerId);
305
- // Monitor connection state
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
416
+ if (earlyIceCandidates.length > 0) {
417
+ this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
418
+ try {
419
+ await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates);
420
+ }
421
+ catch (err) {
422
+ console.error('[Rondevu] Failed to send early ICE candidates:', err);
423
+ }
424
+ }
425
+ // 7. Monitor connection state
306
426
  pc.onconnectionstatechange = () => {
307
427
  this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
308
428
  if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
429
+ this.emit('connection:closed', offerId);
309
430
  this.activeOffers.delete(offerId);
310
431
  this.fillOffers(); // Try to replace failed offer
311
432
  }
@@ -348,6 +469,7 @@ export class Rondevu {
348
469
  });
349
470
  activeOffer.answered = true;
350
471
  this.lastPollTimestamp = answer.answeredAt;
472
+ this.emit('offer:answered', answer.offerId, answer.answererId);
351
473
  // Create replacement offer
352
474
  this.fillOffers();
353
475
  }
@@ -359,6 +481,7 @@ export class Rondevu {
359
481
  const answererCandidates = candidates.filter(c => c.role === 'answerer');
360
482
  for (const item of answererCandidates) {
361
483
  if (item.candidate) {
484
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
362
485
  await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
363
486
  this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
364
487
  }
@@ -411,6 +534,47 @@ export class Rondevu {
411
534
  }
412
535
  this.activeOffers.clear();
413
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
+ }
414
578
  /**
415
579
  * Resolve the full service FQN from various input options
416
580
  * Supports direct FQN, service+username, or service discovery
@@ -426,7 +590,7 @@ export class Rondevu {
426
590
  else if (service) {
427
591
  // Discovery mode - get random service
428
592
  this.debug(`Discovering service: ${service}`);
429
- const discovered = await this.discoverService(service);
593
+ const discovered = await this.findService(service);
430
594
  return discovered.serviceFqn;
431
595
  }
432
596
  else {
@@ -444,6 +608,7 @@ export class Rondevu {
444
608
  const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
445
609
  for (const item of result.candidates) {
446
610
  if (item.candidate) {
611
+ this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
447
612
  await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
448
613
  lastIceTimestamp = item.createdAt;
449
614
  }
@@ -508,6 +673,7 @@ export class Rondevu {
508
673
  pc.ondatachannel = (event) => {
509
674
  this.debug('Data channel received from offerer');
510
675
  dc = event.channel;
676
+ this.emit('connection:opened', serviceData.offerId, dc);
511
677
  resolve(dc);
512
678
  };
513
679
  });
@@ -565,25 +731,41 @@ export class Rondevu {
565
731
  // Service Discovery
566
732
  // ============================================
567
733
  /**
568
- * Get service by FQN (with username) - Direct lookup
569
- * Example: chat:1.0.0@alice
570
- */
571
- async getService(serviceFqn) {
572
- return await this.api.getService(serviceFqn);
573
- }
574
- /**
575
- * Discover a random available service without knowing the username
576
- * Example: chat:1.0.0 (without @username)
577
- */
578
- async discoverService(serviceVersion) {
579
- return await this.api.getService(serviceVersion);
580
- }
581
- /**
582
- * Discover multiple available services with pagination
583
- * 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
+ * ```
584
756
  */
585
- async discoverServices(serviceVersion, limit = 10, offset = 0) {
586
- 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
+ }
587
769
  }
588
770
  // ============================================
589
771
  // WebRTC Signaling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.17.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",