@xtr-dev/rondevu-server 0.0.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,258 @@
1
+ import Database from 'better-sqlite3';
2
+ import { randomUUID } from 'crypto';
3
+ import { Storage, Session } from './types.ts';
4
+
5
+ /**
6
+ * SQLite storage adapter for session management
7
+ * Supports both file-based and in-memory databases
8
+ */
9
+ export class SQLiteStorage implements Storage {
10
+ private db: Database.Database;
11
+
12
+ /**
13
+ * Creates a new SQLite storage instance
14
+ * @param path Path to SQLite database file, or ':memory:' for in-memory database
15
+ */
16
+ constructor(path: string = ':memory:') {
17
+ this.db = new Database(path);
18
+ this.initializeDatabase();
19
+ this.startCleanupInterval();
20
+ }
21
+
22
+ /**
23
+ * Initializes database schema
24
+ */
25
+ private initializeDatabase(): void {
26
+ this.db.exec(`
27
+ CREATE TABLE IF NOT EXISTS sessions (
28
+ code TEXT PRIMARY KEY,
29
+ origin TEXT NOT NULL,
30
+ topic TEXT NOT NULL,
31
+ info TEXT NOT NULL CHECK(length(info) <= 1024),
32
+ offer TEXT NOT NULL,
33
+ answer TEXT,
34
+ offer_candidates TEXT NOT NULL DEFAULT '[]',
35
+ answer_candidates TEXT NOT NULL DEFAULT '[]',
36
+ created_at INTEGER NOT NULL,
37
+ expires_at INTEGER NOT NULL
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at);
41
+ CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
42
+ CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
43
+ `);
44
+ }
45
+
46
+ /**
47
+ * Starts periodic cleanup of expired sessions
48
+ */
49
+ private startCleanupInterval(): void {
50
+ // Run cleanup every minute
51
+ setInterval(() => {
52
+ this.cleanup().catch(err => {
53
+ console.error('Cleanup error:', err);
54
+ });
55
+ }, 60000);
56
+ }
57
+
58
+ /**
59
+ * Generates a unique code using UUID
60
+ */
61
+ private generateCode(): string {
62
+ return randomUUID();
63
+ }
64
+
65
+ async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string> {
66
+ // Validate info length
67
+ if (info.length > 1024) {
68
+ throw new Error('Info string must be 1024 characters or less');
69
+ }
70
+
71
+ let code: string;
72
+ let attempts = 0;
73
+ const maxAttempts = 10;
74
+
75
+ // Try to generate a unique code
76
+ do {
77
+ code = this.generateCode();
78
+ attempts++;
79
+
80
+ if (attempts > maxAttempts) {
81
+ throw new Error('Failed to generate unique session code');
82
+ }
83
+
84
+ try {
85
+ const stmt = this.db.prepare(`
86
+ INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?)
88
+ `);
89
+
90
+ stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt);
91
+ break;
92
+ } catch (err: any) {
93
+ // If unique constraint failed, try again
94
+ if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
95
+ continue;
96
+ }
97
+ throw err;
98
+ }
99
+ } while (true);
100
+
101
+ return code;
102
+ }
103
+
104
+ async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
105
+ const stmt = this.db.prepare(`
106
+ SELECT * FROM sessions
107
+ WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
108
+ ORDER BY created_at DESC
109
+ `);
110
+
111
+ const rows = stmt.all(origin, topic, Date.now()) as any[];
112
+
113
+ return rows.map(row => ({
114
+ code: row.code,
115
+ origin: row.origin,
116
+ topic: row.topic,
117
+ info: row.info,
118
+ offer: row.offer,
119
+ answer: row.answer || undefined,
120
+ offerCandidates: JSON.parse(row.offer_candidates),
121
+ answerCandidates: JSON.parse(row.answer_candidates),
122
+ createdAt: row.created_at,
123
+ expiresAt: row.expires_at,
124
+ }));
125
+ }
126
+
127
+ async listTopics(origin: string, page: number, limit: number): Promise<{
128
+ topics: Array<{ topic: string; count: number }>;
129
+ pagination: {
130
+ page: number;
131
+ limit: number;
132
+ total: number;
133
+ hasMore: boolean;
134
+ };
135
+ }> {
136
+ // Ensure limit doesn't exceed 1000
137
+ const safeLimit = Math.min(Math.max(1, limit), 1000);
138
+ const safePage = Math.max(1, page);
139
+ const offset = (safePage - 1) * safeLimit;
140
+
141
+ // Get total count of topics
142
+ const countStmt = this.db.prepare(`
143
+ SELECT COUNT(DISTINCT topic) as total
144
+ FROM sessions
145
+ WHERE origin = ? AND expires_at > ? AND answer IS NULL
146
+ `);
147
+ const { total } = countStmt.get(origin, Date.now()) as any;
148
+
149
+ // Get paginated topics
150
+ const stmt = this.db.prepare(`
151
+ SELECT topic, COUNT(*) as count
152
+ FROM sessions
153
+ WHERE origin = ? AND expires_at > ? AND answer IS NULL
154
+ GROUP BY topic
155
+ ORDER BY topic ASC
156
+ LIMIT ? OFFSET ?
157
+ `);
158
+
159
+ const rows = stmt.all(origin, Date.now(), safeLimit, offset) as any[];
160
+
161
+ const topics = rows.map(row => ({
162
+ topic: row.topic,
163
+ count: row.count,
164
+ }));
165
+
166
+ return {
167
+ topics,
168
+ pagination: {
169
+ page: safePage,
170
+ limit: safeLimit,
171
+ total,
172
+ hasMore: offset + topics.length < total,
173
+ },
174
+ };
175
+ }
176
+
177
+ async getSession(code: string, origin: string): Promise<Session | null> {
178
+ const stmt = this.db.prepare(`
179
+ SELECT * FROM sessions WHERE code = ? AND origin = ? AND expires_at > ?
180
+ `);
181
+
182
+ const row = stmt.get(code, origin, Date.now()) as any;
183
+
184
+ if (!row) {
185
+ return null;
186
+ }
187
+
188
+ return {
189
+ code: row.code,
190
+ origin: row.origin,
191
+ topic: row.topic,
192
+ info: row.info,
193
+ offer: row.offer,
194
+ answer: row.answer || undefined,
195
+ offerCandidates: JSON.parse(row.offer_candidates),
196
+ answerCandidates: JSON.parse(row.answer_candidates),
197
+ createdAt: row.created_at,
198
+ expiresAt: row.expires_at,
199
+ };
200
+ }
201
+
202
+ async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
203
+ const current = await this.getSession(code, origin);
204
+
205
+ if (!current) {
206
+ throw new Error('Session not found or origin mismatch');
207
+ }
208
+
209
+ const updates: string[] = [];
210
+ const values: any[] = [];
211
+
212
+ if (update.answer !== undefined) {
213
+ updates.push('answer = ?');
214
+ values.push(update.answer);
215
+ }
216
+
217
+ if (update.offerCandidates !== undefined) {
218
+ updates.push('offer_candidates = ?');
219
+ values.push(JSON.stringify(update.offerCandidates));
220
+ }
221
+
222
+ if (update.answerCandidates !== undefined) {
223
+ updates.push('answer_candidates = ?');
224
+ values.push(JSON.stringify(update.answerCandidates));
225
+ }
226
+
227
+ if (updates.length === 0) {
228
+ return;
229
+ }
230
+
231
+ values.push(code);
232
+ values.push(origin);
233
+
234
+ const stmt = this.db.prepare(`
235
+ UPDATE sessions SET ${updates.join(', ')} WHERE code = ? AND origin = ?
236
+ `);
237
+
238
+ stmt.run(...values);
239
+ }
240
+
241
+ async deleteSession(code: string): Promise<void> {
242
+ const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?');
243
+ stmt.run(code);
244
+ }
245
+
246
+ async cleanup(): Promise<void> {
247
+ const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?');
248
+ const result = stmt.run(Date.now());
249
+
250
+ if (result.changes > 0) {
251
+ console.log(`Cleaned up ${result.changes} expired session(s)`);
252
+ }
253
+ }
254
+
255
+ async close(): Promise<void> {
256
+ this.db.close();
257
+ }
258
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Represents a WebRTC signaling session
3
+ */
4
+ export interface Session {
5
+ code: string;
6
+ origin: string;
7
+ topic: string;
8
+ info: string;
9
+ offer: string;
10
+ answer?: string;
11
+ offerCandidates: string[];
12
+ answerCandidates: string[];
13
+ createdAt: number;
14
+ expiresAt: number;
15
+ }
16
+
17
+ /**
18
+ * Storage interface for session management
19
+ * Implementations can use different backends (SQLite, Redis, Memory, etc.)
20
+ */
21
+ export interface Storage {
22
+ /**
23
+ * Creates a new session with the given offer
24
+ * @param origin The Origin header from the request
25
+ * @param topic The topic to post the offer to
26
+ * @param info User info string (max 1024 chars)
27
+ * @param offer The WebRTC SDP offer message
28
+ * @param expiresAt Unix timestamp when the session should expire
29
+ * @returns The unique session code
30
+ */
31
+ createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string>;
32
+
33
+ /**
34
+ * Lists all unanswered sessions for a given origin and topic
35
+ * @param origin The Origin header from the request
36
+ * @param topic The topic to list offers for
37
+ * @returns Array of sessions that haven't been answered yet
38
+ */
39
+ listSessionsByTopic(origin: string, topic: string): Promise<Session[]>;
40
+
41
+ /**
42
+ * Lists all topics for a given origin with their session counts
43
+ * @param origin The Origin header from the request
44
+ * @param page Page number (starting from 1)
45
+ * @param limit Number of results per page (max 1000)
46
+ * @returns Object with topics array and pagination metadata
47
+ */
48
+ listTopics(origin: string, page: number, limit: number): Promise<{
49
+ topics: Array<{ topic: string; count: number }>;
50
+ pagination: {
51
+ page: number;
52
+ limit: number;
53
+ total: number;
54
+ hasMore: boolean;
55
+ };
56
+ }>;
57
+
58
+ /**
59
+ * Retrieves a session by its code
60
+ * @param code The session code
61
+ * @param origin The Origin header from the request (for validation)
62
+ * @returns The session if found, null otherwise
63
+ */
64
+ getSession(code: string, origin: string): Promise<Session | null>;
65
+
66
+ /**
67
+ * Updates an existing session with new data
68
+ * @param code The session code
69
+ * @param origin The Origin header from the request (for validation)
70
+ * @param update Partial session data to update
71
+ */
72
+ updateSession(code: string, origin: string, update: Partial<Session>): Promise<void>;
73
+
74
+ /**
75
+ * Deletes a session
76
+ * @param code The session code
77
+ */
78
+ deleteSession(code: string): Promise<void>;
79
+
80
+ /**
81
+ * Removes expired sessions
82
+ * Should be called periodically to clean up old data
83
+ */
84
+ cleanup(): Promise<void>;
85
+
86
+ /**
87
+ * Closes the storage connection and releases resources
88
+ */
89
+ close(): Promise<void>;
90
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { createApp } from './app.ts';
2
+ import { KVStorage } from './storage/kv.ts';
3
+
4
+ /**
5
+ * Cloudflare Workers environment bindings
6
+ */
7
+ export interface Env {
8
+ SESSIONS: KVNamespace;
9
+ SESSION_TIMEOUT?: string;
10
+ CORS_ORIGINS?: string;
11
+ }
12
+
13
+ /**
14
+ * Cloudflare Workers fetch handler
15
+ */
16
+ export default {
17
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
18
+ // Initialize KV storage
19
+ const storage = new KVStorage(env.SESSIONS);
20
+
21
+ // Parse configuration
22
+ const sessionTimeout = env.SESSION_TIMEOUT
23
+ ? parseInt(env.SESSION_TIMEOUT, 10)
24
+ : 300000; // 5 minutes default
25
+
26
+ const corsOrigins = env.CORS_ORIGINS
27
+ ? env.CORS_ORIGINS.split(',').map(o => o.trim())
28
+ : ['*'];
29
+
30
+ // Create Hono app
31
+ const app = createApp(storage, {
32
+ sessionTimeout,
33
+ corsOrigins,
34
+ });
35
+
36
+ // Handle request
37
+ return app.fetch(request, env, ctx);
38
+ },
39
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "moduleResolution": "bundler",
14
+ "allowImportingTsExtensions": true,
15
+ "noEmit": true,
16
+ "types": ["@types/node", "@cloudflare/workers-types"]
17
+ },
18
+ "include": ["src/**/*.ts"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -0,0 +1,26 @@
1
+ name = "rondevu"
2
+ main = "src/worker.ts"
3
+ compatibility_date = "2024-01-01"
4
+
5
+ # KV Namespace binding
6
+ [[kv_namespaces]]
7
+ binding = "SESSIONS"
8
+ id = "" # Replace with your KV namespace ID
9
+
10
+ # Environment variables
11
+ [vars]
12
+ SESSION_TIMEOUT = "300000" # 5 minutes in milliseconds
13
+ CORS_ORIGINS = "*" # Comma-separated list of allowed origins
14
+
15
+ # Build configuration
16
+ [build]
17
+ command = ""
18
+
19
+ # For local development
20
+ # Run: npx wrangler dev
21
+ # The local KV will be created automatically
22
+
23
+ # For production deployment:
24
+ # 1. Create KV namespace: npx wrangler kv:namespace create SESSIONS
25
+ # 2. Update the 'id' field above with the returned namespace ID
26
+ # 3. Deploy: npx wrangler deploy