@xtr-dev/rondevu-server 0.5.20 → 0.5.22

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.
@@ -0,0 +1,58 @@
1
+ name: Build and Publish Docker Image
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ['v*']
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ env:
11
+ REGISTRY: ghcr.io
12
+ IMAGE_NAME: ${{ github.repository }}
13
+
14
+ jobs:
15
+ build-and-push:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ packages: write
20
+
21
+ steps:
22
+ - name: Checkout repository
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up Docker Buildx
26
+ uses: docker/setup-buildx-action@v3
27
+
28
+ - name: Log in to Container Registry
29
+ if: github.event_name != 'pull_request'
30
+ uses: docker/login-action@v3
31
+ with:
32
+ registry: ${{ env.REGISTRY }}
33
+ username: ${{ github.actor }}
34
+ password: ${{ secrets.GITHUB_TOKEN }}
35
+
36
+ - name: Extract metadata for Docker
37
+ id: meta
38
+ uses: docker/metadata-action@v5
39
+ with:
40
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
41
+ tags: |
42
+ type=ref,event=branch
43
+ type=ref,event=pr
44
+ type=semver,pattern={{version}}
45
+ type=semver,pattern={{major}}.{{minor}}
46
+ type=sha,prefix=
47
+
48
+ - name: Build and push Docker image
49
+ uses: docker/build-push-action@v5
50
+ with:
51
+ context: .
52
+ push: ${{ github.event_name != 'pull_request' }}
53
+ tags: ${{ steps.meta.outputs.tags }}
54
+ labels: ${{ steps.meta.outputs.labels }}
55
+ build-args: |
56
+ VERSION=${{ github.sha }}
57
+ cache-from: type=gha
58
+ cache-to: type=gha,mode=max
package/Dockerfile CHANGED
@@ -1,12 +1,18 @@
1
1
  # Build stage
2
2
  FROM node:20-alpine AS builder
3
3
 
4
+ # Version is passed as build arg (from git commit hash)
5
+ ARG VERSION=unknown
6
+
7
+ # Install build tools for better-sqlite3
8
+ RUN apk add --no-cache python3 make g++
9
+
4
10
  WORKDIR /app
5
11
 
6
12
  # Copy package files
7
13
  COPY package*.json ./
8
14
 
9
- # Install dependencies
15
+ # Install dependencies (including native modules)
10
16
  RUN npm ci
11
17
 
12
18
  # Copy source files
@@ -14,22 +20,30 @@ COPY tsconfig.json ./
14
20
  COPY build.js ./
15
21
  COPY src ./src
16
22
 
17
- # Build TypeScript
18
- RUN npm run build
23
+ # Build TypeScript with version embedded
24
+ RUN VERSION=$VERSION npm run build
19
25
 
20
26
  # Production stage
21
27
  FROM node:20-alpine
22
28
 
29
+ # Install build tools for better-sqlite3 native module
30
+ RUN apk add --no-cache python3 make g++
31
+
23
32
  WORKDIR /app
24
33
 
25
- # Install production dependencies only
34
+ # Copy package files and install production deps
26
35
  COPY package*.json ./
27
36
  RUN npm ci --omit=dev && \
28
- npm cache clean --force
37
+ npm rebuild better-sqlite3 && \
38
+ npm cache clean --force && \
39
+ apk del python3 make g++
29
40
 
30
41
  # Copy built files from builder
31
42
  COPY --from=builder /app/dist ./dist
32
43
 
44
+ # Copy migrations for schema setup
45
+ COPY migrations ./migrations
46
+
33
47
  # Create data directory for SQLite
34
48
  RUN mkdir -p /app/data && \
35
49
  chown -R node:node /app
@@ -39,12 +53,9 @@ USER node
39
53
 
40
54
  # Environment variables with defaults
41
55
  ENV PORT=3000
42
- ENV STORAGE_TYPE=sqlite
43
- ENV STORAGE_PATH=/app/data/sessions.db
44
- ENV SESSION_TIMEOUT=300000
45
- ENV CODE_CHARS=0123456789
46
- ENV CODE_LENGTH=9
56
+ ENV STORAGE_TYPE=memory
47
57
  ENV CORS_ORIGINS=*
58
+ ENV NODE_ENV=production
48
59
 
49
60
  # Expose port
50
61
  EXPOSE 3000
package/build.js CHANGED
@@ -2,13 +2,17 @@
2
2
  const esbuild = require('esbuild');
3
3
  const { execSync } = require('child_process');
4
4
 
5
- // Use git commit hash for version (like Cloudflare Workers deployment)
6
- let version = 'unknown';
7
- try {
8
- version = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
9
- } catch (e) {
10
- console.warn('Could not get git commit hash, using "unknown"');
5
+ // Use VERSION env var first (for Docker builds), then fall back to git commit hash
6
+ let version = process.env.VERSION;
7
+ if (!version || version === 'unknown') {
8
+ try {
9
+ version = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
10
+ } catch (e) {
11
+ console.warn('Could not get git commit hash, using "unknown"');
12
+ version = 'unknown';
13
+ }
11
14
  }
15
+ console.log(`Building with version: ${version}`);
12
16
 
13
17
  esbuild.build({
14
18
  entryPoints: ['src/index.ts'],
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.22",
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/rpc.ts CHANGED
@@ -128,6 +128,10 @@ export interface DiscoverParams {
128
128
  offset?: number;
129
129
  }
130
130
 
131
+ export interface CountOffersByTagsParams {
132
+ tags: string[];
133
+ }
134
+
131
135
  export interface PublishOfferParams {
132
136
  tags: string[];
133
137
  offers: Array<{ sdp: string }>;
@@ -300,6 +304,28 @@ const handlers: Record<string, RpcHandler> = {
300
304
  };
301
305
  },
302
306
 
307
+ /**
308
+ * Count available offers by tags
309
+ */
310
+ async countOffersByTags(params: CountOffersByTagsParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
311
+ const { tags } = params;
312
+
313
+ const tagsValidation = validateTags(tags);
314
+ if (!tagsValidation.valid) {
315
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
316
+ }
317
+
318
+ const counts = await storage.countOffersByTags(tags);
319
+
320
+ // Convert Map to object for JSON serialization
321
+ const result: Record<string, number> = {};
322
+ for (const [tag, count] of counts) {
323
+ result[tag] = count;
324
+ }
325
+
326
+ return { counts: result };
327
+ },
328
+
303
329
  /**
304
330
  * Publish offers with tags
305
331
  */
@@ -669,7 +695,7 @@ const handlers: Record<string, RpcHandler> = {
669
695
  };
670
696
 
671
697
  // Methods that don't require authentication
672
- const UNAUTHENTICATED_METHODS = new Set(['discover']);
698
+ const UNAUTHENTICATED_METHODS = new Set(['discover', 'countOffersByTags']);
673
699
 
674
700
  /**
675
701
  * Handle RPC batch request with header-based authentication
package/src/storage/d1.ts CHANGED
@@ -508,6 +508,28 @@ export class D1Storage implements Storage {
508
508
  return result?.count ?? 0;
509
509
  }
510
510
 
511
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
512
+ const result = new Map<string, number>();
513
+ if (tags.length === 0) return result;
514
+
515
+ const now = Date.now();
516
+
517
+ // Query each tag individually using json_each
518
+ for (const tag of tags) {
519
+ const queryResult = await this.db.prepare(`
520
+ SELECT COUNT(DISTINCT o.id) as count
521
+ FROM offers o, json_each(o.tags) as t
522
+ WHERE t.value = ?
523
+ AND o.expires_at > ?
524
+ AND o.answerer_public_key IS NULL
525
+ `).bind(tag, now).first() as { count: number } | null;
526
+
527
+ result.set(tag, queryResult?.count ?? 0);
528
+ }
529
+
530
+ return result;
531
+ }
532
+
511
533
  // ===== Helper Methods =====
512
534
 
513
535
  private rowToOffer(row: any): Offer {
@@ -445,6 +445,32 @@ export class MemoryStorage implements Storage {
445
445
  return candidates ? candidates.length : 0;
446
446
  }
447
447
 
448
+ async countOffersByTags(tags: string[]): 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
+ let count = 0;
462
+ for (const offerId of offerIds) {
463
+ const offer = this.offers.get(offerId);
464
+ if (offer && offer.expiresAt > now && !offer.answererPublicKey) {
465
+ count++;
466
+ }
467
+ }
468
+ result.set(tag, count);
469
+ }
470
+
471
+ return result;
472
+ }
473
+
448
474
  // ===== Helper Methods =====
449
475
 
450
476
  private removeOfferFromIndexes(offer: Offer): void {
@@ -476,6 +476,28 @@ export class MySQLStorage implements Storage {
476
476
  return Number(rows[0].count);
477
477
  }
478
478
 
479
+ async countOffersByTags(tags: string[]): 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 [rows] = await this.pool.query<RowDataPacket[]>(
488
+ `SELECT COUNT(DISTINCT id) as count
489
+ FROM offers
490
+ WHERE JSON_CONTAINS(tags, ?)
491
+ AND expires_at > ?
492
+ AND answerer_public_key IS NULL`,
493
+ [JSON.stringify(tag), now]
494
+ );
495
+ result.set(tag, Number(rows[0].count));
496
+ }
497
+
498
+ return result;
499
+ }
500
+
479
501
  // ===== Helper Methods =====
480
502
 
481
503
  private rowToOffer(row: RowDataPacket): Offer {
@@ -484,6 +484,28 @@ export class PostgreSQLStorage implements Storage {
484
484
  return Number(result.rows[0].count);
485
485
  }
486
486
 
487
+ async countOffersByTags(tags: string[]): 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 queryResult = await this.pool.query(
496
+ `SELECT COUNT(DISTINCT id) as count
497
+ FROM offers
498
+ WHERE tags ? $1
499
+ AND expires_at > $2
500
+ AND answerer_public_key IS NULL`,
501
+ [tag, now]
502
+ );
503
+ result.set(tag, Number(queryResult.rows[0].count));
504
+ }
505
+
506
+ return result;
507
+ }
508
+
487
509
  // ===== Helper Methods =====
488
510
 
489
511
  private rowToOffer(row: any): Offer {
@@ -543,6 +543,30 @@ export class SQLiteStorage implements Storage {
543
543
  return result.count;
544
544
  }
545
545
 
546
+ async countOffersByTags(tags: string[]): 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 stmt = this.db.prepare(`
555
+ SELECT COUNT(DISTINCT o.id) as count
556
+ FROM offers o, json_each(o.tags) as t
557
+ WHERE t.value = ?
558
+ AND o.expires_at > ?
559
+ AND o.answerer_public_key IS NULL
560
+ `);
561
+
562
+ for (const tag of tags) {
563
+ const row = stmt.get(tag, now) as { count: number };
564
+ result.set(tag, row.count);
565
+ }
566
+
567
+ return result;
568
+ }
569
+
546
570
  // ===== Helper Methods =====
547
571
 
548
572
  /**
@@ -255,4 +255,11 @@ 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
+ * @returns Map of tag to offer count
263
+ */
264
+ countOffersByTags(tags: string[]): Promise<Map<string, number>>;
258
265
  }