@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/src/app.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { Storage } from './storage/types.ts';
4
+
5
+ export interface AppConfig {
6
+ sessionTimeout: number;
7
+ corsOrigins: string[];
8
+ }
9
+
10
+ /**
11
+ * Creates the Hono application with WebRTC signaling endpoints
12
+ */
13
+ export function createApp(storage: Storage, config: AppConfig) {
14
+ const app = new Hono();
15
+
16
+ // Enable CORS
17
+ app.use('/*', cors({
18
+ origin: config.corsOrigins,
19
+ allowMethods: ['GET', 'POST', 'OPTIONS'],
20
+ allowHeaders: ['Content-Type'],
21
+ exposeHeaders: ['Content-Type'],
22
+ maxAge: 600,
23
+ credentials: true,
24
+ }));
25
+
26
+ /**
27
+ * GET /
28
+ * Lists all topics with their unanswered session counts (paginated)
29
+ * Query params: page (default: 1), limit (default: 100, max: 1000)
30
+ */
31
+ app.get('/', async (c) => {
32
+ try {
33
+ const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
34
+ const page = parseInt(c.req.query('page') || '1', 10);
35
+ const limit = parseInt(c.req.query('limit') || '100', 10);
36
+
37
+ const result = await storage.listTopics(origin, page, limit);
38
+
39
+ return c.json(result);
40
+ } catch (err) {
41
+ console.error('Error listing topics:', err);
42
+ return c.json({ error: 'Internal server error' }, 500);
43
+ }
44
+ });
45
+
46
+ /**
47
+ * GET /:topic/sessions
48
+ * Lists all unanswered sessions for a topic
49
+ */
50
+ app.get('/:topic/sessions', async (c) => {
51
+ try {
52
+ const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
53
+ const topic = c.req.param('topic');
54
+
55
+ if (!topic) {
56
+ return c.json({ error: 'Missing required parameter: topic' }, 400);
57
+ }
58
+
59
+ if (topic.length > 256) {
60
+ return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
61
+ }
62
+
63
+ const sessions = await storage.listSessionsByTopic(origin, topic);
64
+
65
+ return c.json({
66
+ sessions: sessions.map(s => ({
67
+ code: s.code,
68
+ info: s.info,
69
+ offer: s.offer,
70
+ offerCandidates: s.offerCandidates,
71
+ createdAt: s.createdAt,
72
+ expiresAt: s.expiresAt,
73
+ })),
74
+ });
75
+ } catch (err) {
76
+ console.error('Error listing sessions:', err);
77
+ return c.json({ error: 'Internal server error' }, 500);
78
+ }
79
+ });
80
+
81
+ /**
82
+ * POST /:topic/offer
83
+ * Creates a new offer and returns a unique session code
84
+ * Body: { info: string, offer: string }
85
+ */
86
+ app.post('/:topic/offer', async (c) => {
87
+ try {
88
+ const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
89
+ const topic = c.req.param('topic');
90
+ const body = await c.req.json();
91
+ const { info, offer } = body;
92
+
93
+ if (!topic || typeof topic !== 'string') {
94
+ return c.json({ error: 'Missing or invalid required parameter: topic' }, 400);
95
+ }
96
+
97
+ if (topic.length > 256) {
98
+ return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
99
+ }
100
+
101
+ if (!info || typeof info !== 'string') {
102
+ return c.json({ error: 'Missing or invalid required parameter: info' }, 400);
103
+ }
104
+
105
+ if (info.length > 1024) {
106
+ return c.json({ error: 'Info string must be 1024 characters or less' }, 400);
107
+ }
108
+
109
+ if (!offer || typeof offer !== 'string') {
110
+ return c.json({ error: 'Missing or invalid required parameter: offer' }, 400);
111
+ }
112
+
113
+ const expiresAt = Date.now() + config.sessionTimeout;
114
+ const code = await storage.createSession(origin, topic, info, offer, expiresAt);
115
+
116
+ return c.json({ code }, 200);
117
+ } catch (err) {
118
+ console.error('Error creating offer:', err);
119
+ return c.json({ error: 'Internal server error' }, 500);
120
+ }
121
+ });
122
+
123
+ /**
124
+ * POST /answer
125
+ * Responds to an existing offer or sends ICE candidates
126
+ * Body: { code: string, answer?: string, candidate?: string, side: 'offerer' | 'answerer' }
127
+ */
128
+ app.post('/answer', async (c) => {
129
+ try {
130
+ const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
131
+ const body = await c.req.json();
132
+ const { code, answer, candidate, side } = body;
133
+
134
+ if (!code || typeof code !== 'string') {
135
+ return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
136
+ }
137
+
138
+ if (!side || (side !== 'offerer' && side !== 'answerer')) {
139
+ return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
140
+ }
141
+
142
+ if (!answer && !candidate) {
143
+ return c.json({ error: 'Missing required parameter: answer or candidate' }, 400);
144
+ }
145
+
146
+ if (answer && candidate) {
147
+ return c.json({ error: 'Cannot provide both answer and candidate' }, 400);
148
+ }
149
+
150
+ const session = await storage.getSession(code, origin);
151
+
152
+ if (!session) {
153
+ return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
154
+ }
155
+
156
+ if (answer) {
157
+ await storage.updateSession(code, origin, { answer });
158
+ }
159
+
160
+ if (candidate) {
161
+ if (side === 'offerer') {
162
+ const updatedCandidates = [...session.offerCandidates, candidate];
163
+ await storage.updateSession(code, origin, { offerCandidates: updatedCandidates });
164
+ } else {
165
+ const updatedCandidates = [...session.answerCandidates, candidate];
166
+ await storage.updateSession(code, origin, { answerCandidates: updatedCandidates });
167
+ }
168
+ }
169
+
170
+ return c.json({ success: true }, 200);
171
+ } catch (err) {
172
+ console.error('Error handling answer:', err);
173
+ return c.json({ error: 'Internal server error' }, 500);
174
+ }
175
+ });
176
+
177
+ /**
178
+ * POST /poll
179
+ * Polls for session data (offer, answer, ICE candidates)
180
+ * Body: { code: string, side: 'offerer' | 'answerer' }
181
+ */
182
+ app.post('/poll', async (c) => {
183
+ try {
184
+ const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
185
+ const body = await c.req.json();
186
+ const { code, side } = body;
187
+
188
+ if (!code || typeof code !== 'string') {
189
+ return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
190
+ }
191
+
192
+ if (!side || (side !== 'offerer' && side !== 'answerer')) {
193
+ return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
194
+ }
195
+
196
+ const session = await storage.getSession(code, origin);
197
+
198
+ if (!session) {
199
+ return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
200
+ }
201
+
202
+ if (side === 'offerer') {
203
+ return c.json({
204
+ answer: session.answer || null,
205
+ answerCandidates: session.answerCandidates,
206
+ });
207
+ } else {
208
+ return c.json({
209
+ offer: session.offer,
210
+ offerCandidates: session.offerCandidates,
211
+ });
212
+ }
213
+ } catch (err) {
214
+ console.error('Error polling session:', err);
215
+ return c.json({ error: 'Internal server error' }, 500);
216
+ }
217
+ });
218
+
219
+ /**
220
+ * GET /health
221
+ * Health check endpoint
222
+ */
223
+ app.get('/health', (c) => {
224
+ return c.json({ status: 'ok', timestamp: Date.now() });
225
+ });
226
+
227
+ return app;
228
+ }
package/src/config.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Application configuration
3
+ * Reads from environment variables with sensible defaults
4
+ */
5
+ export interface Config {
6
+ port: number;
7
+ storageType: 'sqlite' | 'memory';
8
+ storagePath: string;
9
+ sessionTimeout: number;
10
+ corsOrigins: string[];
11
+ }
12
+
13
+ /**
14
+ * Loads configuration from environment variables
15
+ */
16
+ export function loadConfig(): Config {
17
+ return {
18
+ port: parseInt(process.env.PORT || '3000', 10),
19
+ storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
20
+ storagePath: process.env.STORAGE_PATH || ':memory:',
21
+ sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '300000', 10),
22
+ corsOrigins: process.env.CORS_ORIGINS
23
+ ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
24
+ : ['*'],
25
+ };
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { createApp } from './app.ts';
3
+ import { loadConfig } from './config.ts';
4
+ import { SQLiteStorage } from './storage/sqlite.ts';
5
+ import { Storage } from './storage/types.ts';
6
+
7
+ /**
8
+ * Main entry point for the standalone Node.js server
9
+ */
10
+ async function main() {
11
+ const config = loadConfig();
12
+
13
+ console.log('Starting Rondevu server...');
14
+ console.log('Configuration:', {
15
+ port: config.port,
16
+ storageType: config.storageType,
17
+ storagePath: config.storagePath,
18
+ sessionTimeout: `${config.sessionTimeout}ms`,
19
+ corsOrigins: config.corsOrigins,
20
+ });
21
+
22
+ let storage: Storage;
23
+
24
+ if (config.storageType === 'sqlite') {
25
+ storage = new SQLiteStorage(config.storagePath);
26
+ console.log('Using SQLite storage');
27
+ } else {
28
+ throw new Error('Unsupported storage type');
29
+ }
30
+
31
+ const app = createApp(storage, {
32
+ sessionTimeout: config.sessionTimeout,
33
+ corsOrigins: config.corsOrigins,
34
+ });
35
+
36
+ const server = serve({
37
+ fetch: app.fetch,
38
+ port: config.port,
39
+ });
40
+
41
+ console.log(`Server running on http://localhost:${config.port}`);
42
+
43
+ process.on('SIGINT', async () => {
44
+ console.log('\nShutting down gracefully...');
45
+ await storage.close();
46
+ process.exit(0);
47
+ });
48
+
49
+ process.on('SIGTERM', async () => {
50
+ console.log('\nShutting down gracefully...');
51
+ await storage.close();
52
+ process.exit(0);
53
+ });
54
+ }
55
+
56
+ main().catch((err) => {
57
+ console.error('Fatal error:', err);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,241 @@
1
+ import { Storage, Session } from './types.ts';
2
+
3
+ /**
4
+ * Cloudflare KV storage adapter for session management
5
+ */
6
+ export class KVStorage implements Storage {
7
+ private kv: KVNamespace;
8
+
9
+ /**
10
+ * Creates a new KV storage instance
11
+ * @param kv Cloudflare KV namespace binding
12
+ */
13
+ constructor(kv: KVNamespace) {
14
+ this.kv = kv;
15
+ }
16
+
17
+ /**
18
+ * Generates a unique code using Web Crypto API
19
+ */
20
+ private generateCode(): string {
21
+ return crypto.randomUUID();
22
+ }
23
+
24
+ /**
25
+ * Gets the key for storing a session
26
+ */
27
+ private sessionKey(code: string): string {
28
+ return `session:${code}`;
29
+ }
30
+
31
+ /**
32
+ * Gets the key for the topic index
33
+ */
34
+ private topicIndexKey(origin: string, topic: string): string {
35
+ return `index:${origin}:${topic}`;
36
+ }
37
+
38
+ async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string> {
39
+ // Validate info length
40
+ if (info.length > 1024) {
41
+ throw new Error('Info string must be 1024 characters or less');
42
+ }
43
+
44
+ const code = this.generateCode();
45
+ const createdAt = Date.now();
46
+
47
+ const session: Session = {
48
+ code,
49
+ origin,
50
+ topic,
51
+ info,
52
+ offer,
53
+ answer: undefined,
54
+ offerCandidates: [],
55
+ answerCandidates: [],
56
+ createdAt,
57
+ expiresAt,
58
+ };
59
+
60
+ // Calculate TTL in seconds for KV
61
+ const ttl = Math.max(60, Math.floor((expiresAt - createdAt) / 1000));
62
+
63
+ // Store the session
64
+ await this.kv.put(
65
+ this.sessionKey(code),
66
+ JSON.stringify(session),
67
+ { expirationTtl: ttl }
68
+ );
69
+
70
+ // Update the topic index
71
+ const indexKey = this.topicIndexKey(origin, topic);
72
+ const existingIndex = await this.kv.get(indexKey, 'json') as string[] | null;
73
+ const updatedIndex = existingIndex ? [...existingIndex, code] : [code];
74
+
75
+ // Set index TTL to slightly longer than session TTL to avoid race conditions
76
+ await this.kv.put(
77
+ indexKey,
78
+ JSON.stringify(updatedIndex),
79
+ { expirationTtl: ttl + 300 }
80
+ );
81
+
82
+ return code;
83
+ }
84
+
85
+ async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
86
+ const indexKey = this.topicIndexKey(origin, topic);
87
+ const codes = await this.kv.get(indexKey, 'json') as string[] | null;
88
+
89
+ if (!codes || codes.length === 0) {
90
+ return [];
91
+ }
92
+
93
+ // Fetch all sessions in parallel
94
+ const sessionPromises = codes.map(async (code) => {
95
+ const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
96
+ return sessionData;
97
+ });
98
+
99
+ const sessions = await Promise.all(sessionPromises);
100
+
101
+ // Filter out expired or answered sessions, and null values
102
+ const now = Date.now();
103
+ const validSessions = sessions.filter(
104
+ (session): session is Session =>
105
+ session !== null &&
106
+ session.expiresAt > now &&
107
+ session.answer === undefined
108
+ );
109
+
110
+ // Sort by creation time (newest first)
111
+ return validSessions.sort((a, b) => b.createdAt - a.createdAt);
112
+ }
113
+
114
+ async listTopics(origin: string, page: number, limit: number): Promise<{
115
+ topics: Array<{ topic: string; count: number }>;
116
+ pagination: {
117
+ page: number;
118
+ limit: number;
119
+ total: number;
120
+ hasMore: boolean;
121
+ };
122
+ }> {
123
+ // Ensure limit doesn't exceed 1000
124
+ const safeLimit = Math.min(Math.max(1, limit), 1000);
125
+ const safePage = Math.max(1, page);
126
+
127
+ const prefix = `index:${origin}:`;
128
+ const topicCounts = new Map<string, number>();
129
+
130
+ // List all index keys for this origin
131
+ const list = await this.kv.list({ prefix });
132
+
133
+ // Process each topic index
134
+ for (const key of list.keys) {
135
+ // Extract topic from key: "index:{origin}:{topic}"
136
+ const topic = key.name.substring(prefix.length);
137
+
138
+ // Get the session codes for this topic
139
+ const codes = await this.kv.get(key.name, 'json') as string[] | null;
140
+
141
+ if (!codes || codes.length === 0) {
142
+ continue;
143
+ }
144
+
145
+ // Fetch sessions to count only valid ones (unexpired and unanswered)
146
+ const sessionPromises = codes.map(async (code) => {
147
+ const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
148
+ return sessionData;
149
+ });
150
+
151
+ const sessions = await Promise.all(sessionPromises);
152
+
153
+ // Count valid sessions
154
+ const now = Date.now();
155
+ const validCount = sessions.filter(
156
+ (session) =>
157
+ session !== null &&
158
+ session.expiresAt > now &&
159
+ session.answer === undefined
160
+ ).length;
161
+
162
+ if (validCount > 0) {
163
+ topicCounts.set(topic, validCount);
164
+ }
165
+ }
166
+
167
+ // Convert to array and sort by topic name
168
+ const allTopics = Array.from(topicCounts.entries())
169
+ .map(([topic, count]) => ({ topic, count }))
170
+ .sort((a, b) => a.topic.localeCompare(b.topic));
171
+
172
+ // Apply pagination
173
+ const total = allTopics.length;
174
+ const offset = (safePage - 1) * safeLimit;
175
+ const topics = allTopics.slice(offset, offset + safeLimit);
176
+
177
+ return {
178
+ topics,
179
+ pagination: {
180
+ page: safePage,
181
+ limit: safeLimit,
182
+ total,
183
+ hasMore: offset + topics.length < total,
184
+ },
185
+ };
186
+ }
187
+
188
+ async getSession(code: string, origin: string): Promise<Session | null> {
189
+ const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
190
+
191
+ if (!sessionData) {
192
+ return null;
193
+ }
194
+
195
+ // Validate origin and expiration
196
+ if (sessionData.origin !== origin || sessionData.expiresAt <= Date.now()) {
197
+ return null;
198
+ }
199
+
200
+ return sessionData;
201
+ }
202
+
203
+ async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
204
+ const current = await this.getSession(code, origin);
205
+
206
+ if (!current) {
207
+ throw new Error('Session not found or origin mismatch');
208
+ }
209
+
210
+ // Merge updates
211
+ const updated: Session = {
212
+ ...current,
213
+ ...(update.answer !== undefined && { answer: update.answer }),
214
+ ...(update.offerCandidates !== undefined && { offerCandidates: update.offerCandidates }),
215
+ ...(update.answerCandidates !== undefined && { answerCandidates: update.answerCandidates }),
216
+ };
217
+
218
+ // Calculate remaining TTL
219
+ const ttl = Math.max(60, Math.floor((updated.expiresAt - Date.now()) / 1000));
220
+
221
+ // Update the session
222
+ await this.kv.put(
223
+ this.sessionKey(code),
224
+ JSON.stringify(updated),
225
+ { expirationTtl: ttl }
226
+ );
227
+ }
228
+
229
+ async deleteSession(code: string): Promise<void> {
230
+ await this.kv.delete(this.sessionKey(code));
231
+ }
232
+
233
+ async cleanup(): Promise<void> {
234
+ // KV automatically expires keys based on TTL
235
+ // No manual cleanup needed
236
+ }
237
+
238
+ async close(): Promise<void> {
239
+ // No connection to close for KV
240
+ }
241
+ }