@xtr-dev/rondevu-server 0.0.1 → 0.1.1

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,394 @@
1
+ import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
2
+ import { generateOfferHash } from './hash-id.ts';
3
+
4
+ /**
5
+ * D1 storage adapter for topic-based offer management using Cloudflare D1
6
+ * NOTE: This implementation is a placeholder and needs to be fully tested
7
+ */
8
+ export class D1Storage implements Storage {
9
+ private db: D1Database;
10
+
11
+ /**
12
+ * Creates a new D1 storage instance
13
+ * @param db D1Database instance from Cloudflare Workers environment
14
+ */
15
+ constructor(db: D1Database) {
16
+ this.db = db;
17
+ }
18
+
19
+ /**
20
+ * Initializes database schema with new topic-based structure
21
+ * This should be run once during setup, not on every request
22
+ */
23
+ async initializeDatabase(): Promise<void> {
24
+ await this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS offers (
26
+ id TEXT PRIMARY KEY,
27
+ peer_id TEXT NOT NULL,
28
+ sdp TEXT NOT NULL,
29
+ created_at INTEGER NOT NULL,
30
+ expires_at INTEGER NOT NULL,
31
+ last_seen INTEGER NOT NULL,
32
+ secret TEXT,
33
+ answerer_peer_id TEXT,
34
+ answer_sdp TEXT,
35
+ answered_at INTEGER
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
39
+ CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
40
+ CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
41
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
42
+
43
+ CREATE TABLE IF NOT EXISTS offer_topics (
44
+ offer_id TEXT NOT NULL,
45
+ topic TEXT NOT NULL,
46
+ PRIMARY KEY (offer_id, topic),
47
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
51
+ CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
52
+
53
+ CREATE TABLE IF NOT EXISTS ice_candidates (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ offer_id TEXT NOT NULL,
56
+ peer_id TEXT NOT NULL,
57
+ role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
58
+ candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
59
+ created_at INTEGER NOT NULL,
60
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
64
+ CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
65
+ CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
66
+ `);
67
+ }
68
+
69
+ async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
70
+ const created: Offer[] = [];
71
+
72
+ // D1 doesn't support true transactions yet, so we do this sequentially
73
+ for (const offer of offers) {
74
+ const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
75
+ const now = Date.now();
76
+
77
+ // Insert offer
78
+ await this.db.prepare(`
79
+ INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?)
81
+ `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run();
82
+
83
+ // Insert topics
84
+ for (const topic of offer.topics) {
85
+ await this.db.prepare(`
86
+ INSERT INTO offer_topics (offer_id, topic)
87
+ VALUES (?, ?)
88
+ `).bind(id, topic).run();
89
+ }
90
+
91
+ created.push({
92
+ id,
93
+ peerId: offer.peerId,
94
+ sdp: offer.sdp,
95
+ topics: offer.topics,
96
+ createdAt: now,
97
+ expiresAt: offer.expiresAt,
98
+ lastSeen: now,
99
+ secret: offer.secret,
100
+ });
101
+ }
102
+
103
+ return created;
104
+ }
105
+
106
+ async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
107
+ let query = `
108
+ SELECT DISTINCT o.*
109
+ FROM offers o
110
+ INNER JOIN offer_topics ot ON o.id = ot.offer_id
111
+ WHERE ot.topic = ? AND o.expires_at > ?
112
+ `;
113
+
114
+ const params: any[] = [topic, Date.now()];
115
+
116
+ if (excludePeerIds && excludePeerIds.length > 0) {
117
+ const placeholders = excludePeerIds.map(() => '?').join(',');
118
+ query += ` AND o.peer_id NOT IN (${placeholders})`;
119
+ params.push(...excludePeerIds);
120
+ }
121
+
122
+ query += ' ORDER BY o.last_seen DESC';
123
+
124
+ const result = await this.db.prepare(query).bind(...params).all();
125
+
126
+ if (!result.results) {
127
+ return [];
128
+ }
129
+
130
+ return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
131
+ }
132
+
133
+ async getOffersByPeerId(peerId: string): Promise<Offer[]> {
134
+ const result = await this.db.prepare(`
135
+ SELECT * FROM offers
136
+ WHERE peer_id = ? AND expires_at > ?
137
+ ORDER BY last_seen DESC
138
+ `).bind(peerId, Date.now()).all();
139
+
140
+ if (!result.results) {
141
+ return [];
142
+ }
143
+
144
+ return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
145
+ }
146
+
147
+ async getOfferById(offerId: string): Promise<Offer | null> {
148
+ const result = await this.db.prepare(`
149
+ SELECT * FROM offers
150
+ WHERE id = ? AND expires_at > ?
151
+ `).bind(offerId, Date.now()).first();
152
+
153
+ if (!result) {
154
+ return null;
155
+ }
156
+
157
+ return this.rowToOffer(result as any);
158
+ }
159
+
160
+ async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
161
+ const result = await this.db.prepare(`
162
+ DELETE FROM offers
163
+ WHERE id = ? AND peer_id = ?
164
+ `).bind(offerId, ownerPeerId).run();
165
+
166
+ return (result.meta.changes || 0) > 0;
167
+ }
168
+
169
+ async deleteExpiredOffers(now: number): Promise<number> {
170
+ const result = await this.db.prepare(`
171
+ DELETE FROM offers WHERE expires_at < ?
172
+ `).bind(now).run();
173
+
174
+ return result.meta.changes || 0;
175
+ }
176
+
177
+ async answerOffer(
178
+ offerId: string,
179
+ answererPeerId: string,
180
+ answerSdp: string,
181
+ secret?: string
182
+ ): Promise<{ success: boolean; error?: string }> {
183
+ // Check if offer exists and is not expired
184
+ const offer = await this.getOfferById(offerId);
185
+
186
+ if (!offer) {
187
+ return {
188
+ success: false,
189
+ error: 'Offer not found or expired'
190
+ };
191
+ }
192
+
193
+ // Verify secret if offer is protected
194
+ if (offer.secret && offer.secret !== secret) {
195
+ return {
196
+ success: false,
197
+ error: 'Invalid or missing secret'
198
+ };
199
+ }
200
+
201
+ // Check if offer already has an answerer
202
+ if (offer.answererPeerId) {
203
+ return {
204
+ success: false,
205
+ error: 'Offer already answered'
206
+ };
207
+ }
208
+
209
+ // Update offer with answer
210
+ const result = await this.db.prepare(`
211
+ UPDATE offers
212
+ SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
213
+ WHERE id = ? AND answerer_peer_id IS NULL
214
+ `).bind(answererPeerId, answerSdp, Date.now(), offerId).run();
215
+
216
+ if ((result.meta.changes || 0) === 0) {
217
+ return {
218
+ success: false,
219
+ error: 'Offer already answered (race condition)'
220
+ };
221
+ }
222
+
223
+ return { success: true };
224
+ }
225
+
226
+ async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
227
+ const result = await this.db.prepare(`
228
+ SELECT * FROM offers
229
+ WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
230
+ ORDER BY answered_at DESC
231
+ `).bind(offererPeerId, Date.now()).all();
232
+
233
+ if (!result.results) {
234
+ return [];
235
+ }
236
+
237
+ return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
238
+ }
239
+
240
+ async addIceCandidates(
241
+ offerId: string,
242
+ peerId: string,
243
+ role: 'offerer' | 'answerer',
244
+ candidates: any[]
245
+ ): Promise<number> {
246
+ console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
247
+
248
+ // Give each candidate a unique timestamp to avoid "since" filtering issues
249
+ // D1 doesn't have transactions, so insert one by one
250
+ for (let i = 0; i < candidates.length; i++) {
251
+ const timestamp = Date.now() + i; // Ensure unique timestamps
252
+ await this.db.prepare(`
253
+ INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
254
+ VALUES (?, ?, ?, ?, ?)
255
+ `).bind(
256
+ offerId,
257
+ peerId,
258
+ role,
259
+ JSON.stringify(candidates[i]), // Store full object as JSON
260
+ timestamp
261
+ ).run();
262
+ }
263
+
264
+ return candidates.length;
265
+ }
266
+
267
+ async getIceCandidates(
268
+ offerId: string,
269
+ targetRole: 'offerer' | 'answerer',
270
+ since?: number
271
+ ): Promise<IceCandidate[]> {
272
+ let query = `
273
+ SELECT * FROM ice_candidates
274
+ WHERE offer_id = ? AND role = ?
275
+ `;
276
+
277
+ const params: any[] = [offerId, targetRole];
278
+
279
+ if (since !== undefined) {
280
+ query += ' AND created_at > ?';
281
+ params.push(since);
282
+ }
283
+
284
+ query += ' ORDER BY created_at ASC';
285
+
286
+ console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
287
+ const result = await this.db.prepare(query).bind(...params).all();
288
+ console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
289
+
290
+ if (!result.results) {
291
+ return [];
292
+ }
293
+
294
+ const candidates = result.results.map((row: any) => ({
295
+ id: row.id,
296
+ offerId: row.offer_id,
297
+ peerId: row.peer_id,
298
+ role: row.role,
299
+ candidate: JSON.parse(row.candidate), // Parse JSON back to object
300
+ createdAt: row.created_at,
301
+ }));
302
+
303
+ if (candidates.length > 0) {
304
+ console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
305
+ }
306
+
307
+ return candidates;
308
+ }
309
+
310
+ async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
311
+ topics: TopicInfo[];
312
+ total: number;
313
+ }> {
314
+ const now = Date.now();
315
+
316
+ // Build WHERE clause for startsWith filter
317
+ const whereClause = startsWith
318
+ ? 'o.expires_at > ? AND ot.topic LIKE ?'
319
+ : 'o.expires_at > ?';
320
+
321
+ const startsWithPattern = startsWith ? `${startsWith}%` : null;
322
+
323
+ // Get total count of topics with active offers
324
+ const countQuery = `
325
+ SELECT COUNT(DISTINCT ot.topic) as count
326
+ FROM offer_topics ot
327
+ INNER JOIN offers o ON ot.offer_id = o.id
328
+ WHERE ${whereClause}
329
+ `;
330
+
331
+ const countStmt = this.db.prepare(countQuery);
332
+ const countResult = startsWith
333
+ ? await countStmt.bind(now, startsWithPattern).first()
334
+ : await countStmt.bind(now).first();
335
+
336
+ const total = (countResult as any)?.count || 0;
337
+
338
+ // Get topics with peer counts (paginated)
339
+ const topicsQuery = `
340
+ SELECT
341
+ ot.topic,
342
+ COUNT(DISTINCT o.peer_id) as active_peers
343
+ FROM offer_topics ot
344
+ INNER JOIN offers o ON ot.offer_id = o.id
345
+ WHERE ${whereClause}
346
+ GROUP BY ot.topic
347
+ ORDER BY active_peers DESC, ot.topic ASC
348
+ LIMIT ? OFFSET ?
349
+ `;
350
+
351
+ const topicsStmt = this.db.prepare(topicsQuery);
352
+ const topicsResult = startsWith
353
+ ? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
354
+ : await topicsStmt.bind(now, limit, offset).all();
355
+
356
+ const topics = (topicsResult.results || []).map((row: any) => ({
357
+ topic: row.topic,
358
+ activePeers: row.active_peers,
359
+ }));
360
+
361
+ return { topics, total };
362
+ }
363
+
364
+ async close(): Promise<void> {
365
+ // D1 doesn't require explicit connection closing
366
+ // Connections are managed by the Cloudflare Workers runtime
367
+ }
368
+
369
+ /**
370
+ * Helper method to convert database row to Offer object with topics
371
+ */
372
+ private async rowToOffer(row: any): Promise<Offer> {
373
+ // Get topics for this offer
374
+ const topicResult = await this.db.prepare(`
375
+ SELECT topic FROM offer_topics WHERE offer_id = ?
376
+ `).bind(row.id).all();
377
+
378
+ const topics = topicResult.results?.map((t: any) => t.topic) || [];
379
+
380
+ return {
381
+ id: row.id,
382
+ peerId: row.peer_id,
383
+ sdp: row.sdp,
384
+ topics,
385
+ createdAt: row.created_at,
386
+ expiresAt: row.expires_at,
387
+ lastSeen: row.last_seen,
388
+ secret: row.secret || undefined,
389
+ answererPeerId: row.answerer_peer_id || undefined,
390
+ answerSdp: row.answer_sdp || undefined,
391
+ answeredAt: row.answered_at || undefined,
392
+ };
393
+ }
394
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Generates a content-based offer ID using SHA-256 hash
3
+ * Creates deterministic IDs based on offer content (sdp, topics)
4
+ * PeerID is not included as it's inferred from authentication
5
+ * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
6
+ *
7
+ * @param sdp - The WebRTC SDP offer
8
+ * @param topics - Array of topic strings
9
+ * @returns SHA-256 hash of the sanitized offer content
10
+ */
11
+ export async function generateOfferHash(
12
+ sdp: string,
13
+ topics: string[]
14
+ ): Promise<string> {
15
+ // Sanitize and normalize the offer content
16
+ // Only include core offer content (not peerId - that's inferred from auth)
17
+ const sanitizedOffer = {
18
+ sdp,
19
+ topics: [...topics].sort(), // Sort topics for consistency
20
+ };
21
+
22
+ // Create non-prettified JSON string
23
+ const jsonString = JSON.stringify(sanitizedOffer);
24
+
25
+ // Convert string to Uint8Array for hashing
26
+ const encoder = new TextEncoder();
27
+ const data = encoder.encode(jsonString);
28
+
29
+ // Generate SHA-256 hash
30
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
31
+
32
+ // Convert hash to hex string
33
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
34
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
35
+
36
+ return hashHex;
37
+ }