@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.
- package/.dockerignore +12 -0
- package/API.md +428 -0
- package/DEPLOYMENT.md +346 -0
- package/Dockerfile +57 -0
- package/README.md +242 -0
- package/build.js +17 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +7 -0
- package/package.json +26 -0
- package/src/app.ts +228 -0
- package/src/config.ts +26 -0
- package/src/index.ts +59 -0
- package/src/storage/kv.ts +241 -0
- package/src/storage/sqlite.ts +258 -0
- package/src/storage/types.ts +90 -0
- package/src/worker.ts +39 -0
- package/tsconfig.json +20 -0
- package/wrangler.toml.example +26 -0
|
@@ -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
|