@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
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
|
+
}
|