@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/.github/workflows/docker-publish.yml +59 -0
- package/Dockerfile +21 -10
- package/build.js +10 -6
- package/dist/index.js +106 -4
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/rpc.ts +30 -1
- package/src/storage/d1.ts +69 -40
- package/src/storage/memory.ts +39 -0
- package/src/storage/mysql.ts +23 -0
- package/src/storage/postgres.ts +23 -0
- package/src/storage/sqlite.ts +25 -0
- package/src/storage/types.ts +8 -0
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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 {
|
package/src/storage/memory.ts
CHANGED
|
@@ -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 {
|
package/src/storage/mysql.ts
CHANGED
|
@@ -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 {
|
package/src/storage/postgres.ts
CHANGED
|
@@ -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 {
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -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
|
/**
|
package/src/storage/types.ts
CHANGED
|
@@ -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
|
}
|