@xtr-dev/rondevu-server 0.5.20 → 0.5.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-server",
3
- "version": "0.5.20",
3
+ "version": "0.5.25",
4
4
  "description": "DNS-like WebRTC signaling server with credential-based authentication and service discovery",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
package/src/config.ts CHANGED
@@ -95,7 +95,7 @@ export const CONFIG_DEFAULTS = {
95
95
  offerDefaultTtl: 60000,
96
96
  offerMaxTtl: 86400000,
97
97
  offerMinTtl: 60000,
98
- answeredOfferTtl: 30000, // 30 seconds TTL after offer is answered
98
+ answeredOfferTtl: 60000, // 60 seconds TTL after offer is answered
99
99
  cleanupInterval: 60000,
100
100
  maxOffersPerRequest: 100,
101
101
  maxBatchSize: 100,
package/src/rpc.ts CHANGED
@@ -128,6 +128,11 @@ export interface DiscoverParams {
128
128
  offset?: number;
129
129
  }
130
130
 
131
+ export interface CountOffersByTagsParams {
132
+ tags: string[];
133
+ unique?: boolean;
134
+ }
135
+
131
136
  export interface PublishOfferParams {
132
137
  tags: string[];
133
138
  offers: Array<{ sdp: string }>;
@@ -300,6 +305,28 @@ const handlers: Record<string, RpcHandler> = {
300
305
  };
301
306
  },
302
307
 
308
+ /**
309
+ * Count available offers by tags
310
+ */
311
+ async countOffersByTags(params: CountOffersByTagsParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
312
+ const { tags, unique } = params;
313
+
314
+ const tagsValidation = validateTags(tags);
315
+ if (!tagsValidation.valid) {
316
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
317
+ }
318
+
319
+ const counts = await storage.countOffersByTags(tags, unique === true);
320
+
321
+ // Convert Map to object for JSON serialization
322
+ const result: Record<string, number> = {};
323
+ for (const [tag, count] of counts) {
324
+ result[tag] = count;
325
+ }
326
+
327
+ return { counts: result };
328
+ },
329
+
303
330
  /**
304
331
  * Publish offers with tags
305
332
  */
@@ -456,6 +483,7 @@ const handlers: Record<string, RpcHandler> = {
456
483
 
457
484
  // Reduce TTL after answer for faster cleanup (answered offers no longer appear in discovery)
458
485
  const newExpiresAt = Date.now() + config.answeredOfferTtl;
486
+
459
487
  await storage.answerOffer(offerId, publicKey, sdp, matchedTags, newExpiresAt);
460
488
 
461
489
  return { success: true, offerId };
@@ -510,6 +538,7 @@ const handlers: Record<string, RpcHandler> = {
510
538
  const sinceTimestamp = since !== undefined ? since : 0;
511
539
 
512
540
  const answeredOffers = await storage.getAnsweredOffers(publicKey);
541
+
513
542
  const filteredAnswers = answeredOffers.filter(
514
543
  (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
515
544
  );
@@ -669,7 +698,7 @@ const handlers: Record<string, RpcHandler> = {
669
698
  };
670
699
 
671
700
  // Methods that don't require authentication
672
- const UNAUTHENTICATED_METHODS = new Set(['discover']);
701
+ const UNAUTHENTICATED_METHODS = new Set(['discover', 'countOffersByTags']);
673
702
 
674
703
  /**
675
704
  * Handle RPC batch request with header-based authentication
package/src/storage/d1.ts CHANGED
@@ -378,48 +378,54 @@ export class D1Storage implements Storage {
378
378
  throw new Error('Too many offer IDs (max 1000)');
379
379
  }
380
380
 
381
- const placeholders = offerIds.map(() => '?').join(',');
382
-
383
- let query = `
384
- SELECT ic.*, o.public_key as offer_public_key
385
- FROM ice_candidates ic
386
- INNER JOIN offers o ON o.id = ic.offer_id
387
- WHERE ic.offer_id IN (${placeholders})
388
- AND (
389
- (o.public_key = ? AND ic.role = 'answerer')
390
- OR (o.answerer_public_key = ? AND ic.role = 'offerer')
391
- )
392
- `;
393
-
394
- const params: any[] = [...offerIds, publicKey, publicKey];
395
-
396
- if (since !== undefined) {
397
- query += ' AND ic.created_at > ?';
398
- params.push(since);
399
- }
400
-
401
- query += ' ORDER BY ic.created_at ASC';
402
-
403
- const queryResult = await this.db.prepare(query).bind(...params).all();
404
-
405
- if (!queryResult.results) {
406
- return result;
407
- }
381
+ // D1 has a limit of ~100 bind parameters per query, so batch queries
382
+ // Each query uses: offerIds.length + 2 (publicKey twice) + 1 (optional since) params
383
+ // Use batches of 90 offer IDs to stay safely under the limit
384
+ const BATCH_SIZE = 90;
385
+
386
+ for (let i = 0; i < offerIds.length; i += BATCH_SIZE) {
387
+ const batchOfferIds = offerIds.slice(i, i + BATCH_SIZE);
388
+ const placeholders = batchOfferIds.map(() => '?').join(',');
389
+
390
+ let query = `
391
+ SELECT ic.*, o.public_key as offer_public_key
392
+ FROM ice_candidates ic
393
+ INNER JOIN offers o ON o.id = ic.offer_id
394
+ WHERE ic.offer_id IN (${placeholders})
395
+ AND (
396
+ (o.public_key = ? AND ic.role = 'answerer')
397
+ OR (o.answerer_public_key = ? AND ic.role = 'offerer')
398
+ )
399
+ `;
400
+
401
+ const params: any[] = [...batchOfferIds, publicKey, publicKey];
402
+
403
+ if (since !== undefined) {
404
+ query += ' AND ic.created_at > ?';
405
+ params.push(since);
406
+ }
408
407
 
409
- for (const row of queryResult.results as any[]) {
410
- const candidate: IceCandidate = {
411
- id: row.id,
412
- offerId: row.offer_id,
413
- publicKey: row.public_key,
414
- role: row.role,
415
- candidate: JSON.parse(row.candidate),
416
- createdAt: row.created_at,
417
- };
418
-
419
- if (!result.has(row.offer_id)) {
420
- result.set(row.offer_id, []);
408
+ query += ' ORDER BY ic.created_at ASC';
409
+
410
+ const queryResult = await this.db.prepare(query).bind(...params).all();
411
+
412
+ if (queryResult.results) {
413
+ for (const row of queryResult.results as any[]) {
414
+ const candidate: IceCandidate = {
415
+ id: row.id,
416
+ offerId: row.offer_id,
417
+ publicKey: row.public_key,
418
+ role: row.role,
419
+ candidate: JSON.parse(row.candidate),
420
+ createdAt: row.created_at,
421
+ };
422
+
423
+ if (!result.has(row.offer_id)) {
424
+ result.set(row.offer_id, []);
425
+ }
426
+ result.get(row.offer_id)!.push(candidate);
427
+ }
421
428
  }
422
- result.get(row.offer_id)!.push(candidate);
423
429
  }
424
430
 
425
431
  return result;
@@ -508,6 +514,29 @@ export class D1Storage implements Storage {
508
514
  return result?.count ?? 0;
509
515
  }
510
516
 
517
+ async countOffersByTags(tags: string[], unique = false): Promise<Map<string, number>> {
518
+ const result = new Map<string, number>();
519
+ if (tags.length === 0) return result;
520
+
521
+ const now = Date.now();
522
+
523
+ // Query each tag individually using json_each
524
+ for (const tag of tags) {
525
+ const countColumn = unique ? 'COUNT(DISTINCT o.public_key)' : 'COUNT(DISTINCT o.id)';
526
+ const queryResult = await this.db.prepare(`
527
+ SELECT ${countColumn} as count
528
+ FROM offers o, json_each(o.tags) as t
529
+ WHERE t.value = ?
530
+ AND o.expires_at > ?
531
+ AND o.answerer_public_key IS NULL
532
+ `).bind(tag, now).first() as { count: number } | null;
533
+
534
+ result.set(tag, queryResult?.count ?? 0);
535
+ }
536
+
537
+ return result;
538
+ }
539
+
511
540
  // ===== Helper Methods =====
512
541
 
513
542
  private rowToOffer(row: any): Offer {
@@ -445,6 +445,45 @@ export class MemoryStorage implements Storage {
445
445
  return candidates ? candidates.length : 0;
446
446
  }
447
447
 
448
+ async countOffersByTags(tags: string[], unique = false): Promise<Map<string, number>> {
449
+ const result = new Map<string, number>();
450
+ if (tags.length === 0) return result;
451
+
452
+ const now = Date.now();
453
+
454
+ for (const tag of tags) {
455
+ const offerIds = this.offersByTag.get(tag);
456
+ if (!offerIds) {
457
+ result.set(tag, 0);
458
+ continue;
459
+ }
460
+
461
+ if (unique) {
462
+ // Count unique public keys
463
+ const uniquePublicKeys = new Set<string>();
464
+ for (const offerId of offerIds) {
465
+ const offer = this.offers.get(offerId);
466
+ if (offer && offer.expiresAt > now && !offer.answererPublicKey) {
467
+ uniquePublicKeys.add(offer.publicKey);
468
+ }
469
+ }
470
+ result.set(tag, uniquePublicKeys.size);
471
+ } else {
472
+ // Count total offers
473
+ let count = 0;
474
+ for (const offerId of offerIds) {
475
+ const offer = this.offers.get(offerId);
476
+ if (offer && offer.expiresAt > now && !offer.answererPublicKey) {
477
+ count++;
478
+ }
479
+ }
480
+ result.set(tag, count);
481
+ }
482
+ }
483
+
484
+ return result;
485
+ }
486
+
448
487
  // ===== Helper Methods =====
449
488
 
450
489
  private removeOfferFromIndexes(offer: Offer): void {
@@ -476,6 +476,29 @@ export class MySQLStorage implements Storage {
476
476
  return Number(rows[0].count);
477
477
  }
478
478
 
479
+ async countOffersByTags(tags: string[], unique = false): Promise<Map<string, number>> {
480
+ const result = new Map<string, number>();
481
+ if (tags.length === 0) return result;
482
+
483
+ const now = Date.now();
484
+
485
+ // Query each tag individually using JSON_CONTAINS
486
+ for (const tag of tags) {
487
+ const countColumn = unique ? 'COUNT(DISTINCT public_key)' : 'COUNT(DISTINCT id)';
488
+ const [rows] = await this.pool.query<RowDataPacket[]>(
489
+ `SELECT ${countColumn} as count
490
+ FROM offers
491
+ WHERE JSON_CONTAINS(tags, ?)
492
+ AND expires_at > ?
493
+ AND answerer_public_key IS NULL`,
494
+ [JSON.stringify(tag), now]
495
+ );
496
+ result.set(tag, Number(rows[0].count));
497
+ }
498
+
499
+ return result;
500
+ }
501
+
479
502
  // ===== Helper Methods =====
480
503
 
481
504
  private rowToOffer(row: RowDataPacket): Offer {
@@ -484,6 +484,29 @@ export class PostgreSQLStorage implements Storage {
484
484
  return Number(result.rows[0].count);
485
485
  }
486
486
 
487
+ async countOffersByTags(tags: string[], unique = false): Promise<Map<string, number>> {
488
+ const result = new Map<string, number>();
489
+ if (tags.length === 0) return result;
490
+
491
+ const now = Date.now();
492
+
493
+ // Query each tag individually using JSONB containment
494
+ for (const tag of tags) {
495
+ const countColumn = unique ? 'COUNT(DISTINCT public_key)' : 'COUNT(DISTINCT id)';
496
+ const queryResult = await this.pool.query(
497
+ `SELECT ${countColumn} as count
498
+ FROM offers
499
+ WHERE tags ? $1
500
+ AND expires_at > $2
501
+ AND answerer_public_key IS NULL`,
502
+ [tag, now]
503
+ );
504
+ result.set(tag, Number(queryResult.rows[0].count));
505
+ }
506
+
507
+ return result;
508
+ }
509
+
487
510
  // ===== Helper Methods =====
488
511
 
489
512
  private rowToOffer(row: any): Offer {
@@ -543,6 +543,31 @@ export class SQLiteStorage implements Storage {
543
543
  return result.count;
544
544
  }
545
545
 
546
+ async countOffersByTags(tags: string[], unique = false): Promise<Map<string, number>> {
547
+ const result = new Map<string, number>();
548
+ if (tags.length === 0) return result;
549
+
550
+ const now = Date.now();
551
+
552
+ // Query counts for each tag individually for accuracy
553
+ // (an offer with multiple matching tags should only count once per tag)
554
+ const countColumn = unique ? 'COUNT(DISTINCT o.public_key)' : 'COUNT(DISTINCT o.id)';
555
+ const stmt = this.db.prepare(`
556
+ SELECT ${countColumn} as count
557
+ FROM offers o, json_each(o.tags) as t
558
+ WHERE t.value = ?
559
+ AND o.expires_at > ?
560
+ AND o.answerer_public_key IS NULL
561
+ `);
562
+
563
+ for (const tag of tags) {
564
+ const row = stmt.get(tag, now) as { count: number };
565
+ result.set(tag, row.count);
566
+ }
567
+
568
+ return result;
569
+ }
570
+
546
571
  // ===== Helper Methods =====
547
572
 
548
573
  /**
@@ -255,4 +255,12 @@ export interface Storage {
255
255
  * @returns ICE candidate count for offer
256
256
  */
257
257
  getIceCandidateCount(offerId: string): Promise<number>;
258
+
259
+ /**
260
+ * Counts available (unanswered, non-expired) offers for each of the given tags
261
+ * @param tags Array of tags to count offers for
262
+ * @param unique If true, count unique public keys instead of total offers
263
+ * @returns Map of tag to count
264
+ */
265
+ countOffersByTags(tags: string[], unique?: boolean): Promise<Map<string, number>>;
258
266
  }