@xtr-dev/rondevu-client 0.21.8 → 0.21.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.
@@ -18,6 +18,12 @@ export interface DiscoverRequest {
18
18
  limit?: number;
19
19
  offset?: number;
20
20
  }
21
+ export interface CountOffersByTagsRequest {
22
+ tags: string[];
23
+ }
24
+ export interface CountOffersByTagsResponse {
25
+ counts: Record<string, number>;
26
+ }
21
27
  export interface TaggedOffer {
22
28
  offerId: string;
23
29
  publicKey: string;
@@ -127,6 +133,13 @@ export declare class RondevuAPI {
127
133
  * @returns Paginated response if limit provided, single offer if not
128
134
  */
129
135
  discover(request: DiscoverRequest): Promise<DiscoverResponse | TaggedOffer>;
136
+ /**
137
+ * Count available offers by tags
138
+ * Returns the count of available (unanswered, non-expired) offers for each tag
139
+ * @param request - Request with tags to count
140
+ * @returns Object mapping each tag to its offer count
141
+ */
142
+ countOffersByTags(request: CountOffersByTagsRequest): Promise<CountOffersByTagsResponse>;
130
143
  /**
131
144
  * Delete an offer by ID
132
145
  */
@@ -211,6 +211,22 @@ export class RondevuAPI {
211
211
  const authHeaders = await this.generateAuthHeaders(rpcRequest);
212
212
  return await this.rpc(rpcRequest, authHeaders);
213
213
  }
214
+ /**
215
+ * Count available offers by tags
216
+ * Returns the count of available (unanswered, non-expired) offers for each tag
217
+ * @param request - Request with tags to count
218
+ * @returns Object mapping each tag to its offer count
219
+ */
220
+ async countOffersByTags(request) {
221
+ const rpcRequest = {
222
+ method: 'countOffersByTags',
223
+ params: {
224
+ tags: request.tags,
225
+ },
226
+ };
227
+ const authHeaders = await this.generateAuthHeaders(rpcRequest);
228
+ return await this.rpc(rpcRequest, authHeaders);
229
+ }
214
230
  /**
215
231
  * Delete an offer by ID
216
232
  */
@@ -6,7 +6,7 @@
6
6
  * Advanced options use sensible defaults.
7
7
  */
8
8
  export interface ConnectionOptions {
9
- /** Maximum time to wait for connection (ms). Default: 30000 */
9
+ /** Maximum time to wait for connection (ms). Default: 10000 */
10
10
  timeout?: number;
11
11
  /** Enable automatic reconnection on failures. Default: true */
12
12
  reconnect?: boolean;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const DEFAULT_CONNECTION_CONFIG = {
5
5
  // Timeouts
6
- connectionTimeout: 30000, // 30 seconds
6
+ connectionTimeout: 10000, // 10 seconds
7
7
  iceGatheringTimeout: 10000, // 10 seconds
8
8
  // Reconnection
9
9
  reconnectEnabled: true,
@@ -52,6 +52,7 @@ export declare class OfferPool extends EventEmitter<OfferPoolEvents> {
52
52
  private readonly debugEnabled;
53
53
  private readonly offerCreationThrottleMs;
54
54
  private readonly activeConnections;
55
+ private readonly rotatedOfferIds;
55
56
  private readonly matchedTagsByOffer;
56
57
  private readonly fillLock;
57
58
  private running;
@@ -123,6 +124,11 @@ export declare class OfferPool extends EventEmitter<OfferPoolEvents> {
123
124
  * Called by Rondevu when a poll:ice event is received
124
125
  */
125
126
  handlePollIce(data: PollIceEvent): void;
127
+ /**
128
+ * Resolve an offerId through the rotation chain to find the current offerId
129
+ * Returns the final offerId or undefined if not found
130
+ */
131
+ private resolveRotatedOfferId;
126
132
  /**
127
133
  * Debug logging (only if debug enabled)
128
134
  */
@@ -11,6 +11,7 @@ export class OfferPool extends EventEmitter {
11
11
  super();
12
12
  // State
13
13
  this.activeConnections = new Map();
14
+ this.rotatedOfferIds = new Map(); // Maps old offerId -> new offerId for late-arriving answers
14
15
  this.matchedTagsByOffer = new Map(); // Track matchedTags from answers
15
16
  this.fillLock = new AsyncLock();
16
17
  this.running = false;
@@ -57,6 +58,8 @@ export class OfferPool extends EventEmitter {
57
58
  connection.close();
58
59
  }
59
60
  this.activeConnections.clear();
61
+ this.rotatedOfferIds.clear();
62
+ this.matchedTagsByOffer.clear();
60
63
  }
61
64
  /**
62
65
  * Get count of active offers
@@ -230,20 +233,37 @@ export class OfferPool extends EventEmitter {
230
233
  return;
231
234
  }
232
235
  this.debug(`Proceeding with rotation for offer ${currentOfferId}`);
236
+ // Track new RTCPeerConnection for cleanup if rotation fails
237
+ let newPcForCleanup = null;
233
238
  try {
234
239
  // Create new offer and rebind existing connection
235
240
  const { newOfferId, pc, dc } = await this.createNewOfferForRotation();
236
- // Rebind the connection to new offer
241
+ newPcForCleanup = pc; // Track for cleanup if rebind fails
242
+ // Rebind the connection to new offer (this closes the old pc)
237
243
  await connection.rebindToOffer(newOfferId, pc, dc);
244
+ newPcForCleanup = null; // Rebind succeeded, pc is now managed by connection
238
245
  // Update map: remove old offerId, add new offerId with same connection
239
246
  this.activeConnections.delete(currentOfferId);
240
247
  this.activeConnections.set(newOfferId, connection);
248
+ // Track rotation so late-arriving answers for old offerId can be forwarded
249
+ this.rotatedOfferIds.set(currentOfferId, newOfferId);
241
250
  this.emit('connection:rotated', currentOfferId, newOfferId, connection);
242
251
  this.debug(`Connection rotated: ${currentOfferId} → ${newOfferId}`);
243
252
  }
244
253
  catch (rotationError) {
245
- // If rotation fails, fall back to destroying connection
254
+ // If rotation fails, clean up all RTCPeerConnections to prevent resource leak
246
255
  this.debug(`Rotation failed for ${currentOfferId}:`, rotationError);
256
+ // Close the new pc if it was created but rebind failed
257
+ if (newPcForCleanup) {
258
+ try {
259
+ newPcForCleanup.close();
260
+ }
261
+ catch {
262
+ // Ignore close errors
263
+ }
264
+ }
265
+ // Close the old connection (closes its RTCPeerConnection)
266
+ connection.close();
247
267
  this.activeConnections.delete(currentOfferId);
248
268
  this.emit('offer:failed', currentOfferId, error);
249
269
  this.fillOffers(); // Create replacement
@@ -268,12 +288,25 @@ export class OfferPool extends EventEmitter {
268
288
  async handlePollAnswer(data) {
269
289
  if (!this.running)
270
290
  return;
271
- const connection = this.activeConnections.get(data.offerId);
291
+ // Find connection - check direct mapping first, then rotated offers
292
+ let connection = this.activeConnections.get(data.offerId);
293
+ let effectiveOfferId = data.offerId;
294
+ if (!connection) {
295
+ // Check if this is a late-arriving answer for a rotated offer
296
+ const newOfferId = this.resolveRotatedOfferId(data.offerId);
297
+ if (newOfferId && newOfferId !== data.offerId) {
298
+ connection = this.activeConnections.get(newOfferId);
299
+ effectiveOfferId = newOfferId;
300
+ if (connection) {
301
+ this.debug(`Late answer for rotated offer ${data.offerId} → forwarding to ${newOfferId}`);
302
+ }
303
+ }
304
+ }
272
305
  if (connection) {
273
- this.debug(`Processing answer for offer ${data.offerId}`);
306
+ this.debug(`Processing answer for offer ${effectiveOfferId}`);
274
307
  // Store matchedTags for when connection opens
275
308
  if (data.matchedTags) {
276
- this.matchedTagsByOffer.set(data.offerId, data.matchedTags);
309
+ this.matchedTagsByOffer.set(effectiveOfferId, data.matchedTags);
277
310
  }
278
311
  try {
279
312
  await connection.processAnswer(data.sdp, data.answererPublicKey);
@@ -281,7 +314,7 @@ export class OfferPool extends EventEmitter {
281
314
  this.fillOffers();
282
315
  }
283
316
  catch (err) {
284
- this.debug(`Failed to process answer for offer ${data.offerId}:`, err);
317
+ this.debug(`Failed to process answer for offer ${effectiveOfferId}:`, err);
285
318
  }
286
319
  }
287
320
  // Silently ignore answers for offers we don't have - they may be for other connections
@@ -293,13 +326,42 @@ export class OfferPool extends EventEmitter {
293
326
  handlePollIce(data) {
294
327
  if (!this.running)
295
328
  return;
296
- const connection = this.activeConnections.get(data.offerId);
329
+ // Find connection - check direct mapping first, then rotated offers
330
+ let connection = this.activeConnections.get(data.offerId);
331
+ if (!connection) {
332
+ // Check if this is late-arriving ICE for a rotated offer
333
+ const newOfferId = this.resolveRotatedOfferId(data.offerId);
334
+ if (newOfferId && newOfferId !== data.offerId) {
335
+ connection = this.activeConnections.get(newOfferId);
336
+ if (connection) {
337
+ this.debug(`Late ICE for rotated offer ${data.offerId} → forwarding to ${newOfferId}`);
338
+ }
339
+ }
340
+ }
297
341
  if (connection) {
298
342
  this.debug(`Processing ${data.candidates.length} ICE candidates for offer ${data.offerId}`);
299
343
  connection.handleRemoteIceCandidates(data.candidates);
300
344
  }
301
345
  // Silently ignore ICE candidates for offers we don't have
302
346
  }
347
+ /**
348
+ * Resolve an offerId through the rotation chain to find the current offerId
349
+ * Returns the final offerId or undefined if not found
350
+ */
351
+ resolveRotatedOfferId(offerId) {
352
+ let currentId = offerId;
353
+ const visited = new Set();
354
+ while (this.rotatedOfferIds.has(currentId)) {
355
+ if (visited.has(currentId)) {
356
+ // Circular reference - shouldn't happen but protect against it
357
+ this.debug(`Circular rotation detected for ${offerId}`);
358
+ return undefined;
359
+ }
360
+ visited.add(currentId);
361
+ currentId = this.rotatedOfferIds.get(currentId);
362
+ }
363
+ return currentId !== offerId ? currentId : undefined;
364
+ }
303
365
  /**
304
366
  * Debug logging (only if debug enabled)
305
367
  */
@@ -255,6 +255,22 @@ export declare class Rondevu extends EventEmitter {
255
255
  * ```
256
256
  */
257
257
  discover(tags: string[], options?: DiscoverOptions): Promise<DiscoverResult>;
258
+ /**
259
+ * Count available offers by tags
260
+ * Returns the count of available (unanswered, non-expired) offers for each tag
261
+ *
262
+ * @param tags - Tags to count offers for
263
+ * @returns Object mapping each tag to its offer count
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * const counts = await rondevu.countOffersByTags(['chat', 'video'])
268
+ * console.log(counts.counts) // { chat: 5, video: 3 }
269
+ * ```
270
+ */
271
+ countOffersByTags(tags: string[]): Promise<{
272
+ counts: Record<string, number>;
273
+ }>;
258
274
  /**
259
275
  * Post answer SDP to specific offer
260
276
  */
@@ -446,6 +446,22 @@ export class Rondevu extends EventEmitter {
446
446
  // Always pass limit to ensure we get DiscoverResponse (paginated mode)
447
447
  return (await this.api.discover({ tags, limit, offset }));
448
448
  }
449
+ /**
450
+ * Count available offers by tags
451
+ * Returns the count of available (unanswered, non-expired) offers for each tag
452
+ *
453
+ * @param tags - Tags to count offers for
454
+ * @returns Object mapping each tag to its offer count
455
+ *
456
+ * @example
457
+ * ```typescript
458
+ * const counts = await rondevu.countOffersByTags(['chat', 'video'])
459
+ * console.log(counts.counts) // { chat: 5, video: 3 }
460
+ * ```
461
+ */
462
+ async countOffersByTags(tags) {
463
+ return await this.api.countOffersByTags({ tags });
464
+ }
449
465
  // ============================================
450
466
  // WebRTC Signaling
451
467
  // ============================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.21.8",
3
+ "version": "0.21.10",
4
4
  "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
5
5
  "type": "module",
6
6
  "main": "dist/core/index.js",