mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-10 19:03:24 +00:00
Initial commit: Rondevu signaling server
Open signaling and tracking server for peer discovery in distributed P2P applications. Features: - REST API for WebRTC peer discovery and signaling - Origin-based session isolation - Multiple storage backends (SQLite, in-memory, Cloudflare KV) - Docker and Cloudflare Workers deployment support - Automatic session cleanup and expiration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
228
src/app.ts
Normal file
228
src/app.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Storage } from './storage/types.ts';
|
||||
|
||||
export interface AppConfig {
|
||||
sessionTimeout: number;
|
||||
corsOrigins: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Hono application with WebRTC signaling endpoints
|
||||
*/
|
||||
export function createApp(storage: Storage, config: AppConfig) {
|
||||
const app = new Hono();
|
||||
|
||||
// Enable CORS
|
||||
app.use('/*', cors({
|
||||
origin: config.corsOrigins,
|
||||
allowMethods: ['GET', 'POST', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type'],
|
||||
exposeHeaders: ['Content-Type'],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Lists all topics with their unanswered session counts (paginated)
|
||||
* Query params: page (default: 1), limit (default: 100, max: 1000)
|
||||
*/
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
||||
const page = parseInt(c.req.query('page') || '1', 10);
|
||||
const limit = parseInt(c.req.query('limit') || '100', 10);
|
||||
|
||||
const result = await storage.listTopics(origin, page, limit);
|
||||
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
console.error('Error listing topics:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:topic/sessions
|
||||
* Lists all unanswered sessions for a topic
|
||||
*/
|
||||
app.get('/:topic/sessions', async (c) => {
|
||||
try {
|
||||
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
||||
const topic = c.req.param('topic');
|
||||
|
||||
if (!topic) {
|
||||
return c.json({ error: 'Missing required parameter: topic' }, 400);
|
||||
}
|
||||
|
||||
if (topic.length > 256) {
|
||||
return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
|
||||
}
|
||||
|
||||
const sessions = await storage.listSessionsByTopic(origin, topic);
|
||||
|
||||
return c.json({
|
||||
sessions: sessions.map(s => ({
|
||||
code: s.code,
|
||||
info: s.info,
|
||||
offer: s.offer,
|
||||
offerCandidates: s.offerCandidates,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error listing sessions:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:topic/offer
|
||||
* Creates a new offer and returns a unique session code
|
||||
* Body: { info: string, offer: string }
|
||||
*/
|
||||
app.post('/:topic/offer', async (c) => {
|
||||
try {
|
||||
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
||||
const topic = c.req.param('topic');
|
||||
const body = await c.req.json();
|
||||
const { info, offer } = body;
|
||||
|
||||
if (!topic || typeof topic !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: topic' }, 400);
|
||||
}
|
||||
|
||||
if (topic.length > 256) {
|
||||
return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
|
||||
}
|
||||
|
||||
if (!info || typeof info !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: info' }, 400);
|
||||
}
|
||||
|
||||
if (info.length > 1024) {
|
||||
return c.json({ error: 'Info string must be 1024 characters or less' }, 400);
|
||||
}
|
||||
|
||||
if (!offer || typeof offer !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: offer' }, 400);
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + config.sessionTimeout;
|
||||
const code = await storage.createSession(origin, topic, info, offer, expiresAt);
|
||||
|
||||
return c.json({ code }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error creating offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /answer
|
||||
* Responds to an existing offer or sends ICE candidates
|
||||
* Body: { code: string, answer?: string, candidate?: string, side: 'offerer' | 'answerer' }
|
||||
*/
|
||||
app.post('/answer', async (c) => {
|
||||
try {
|
||||
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
||||
const body = await c.req.json();
|
||||
const { code, answer, candidate, side } = body;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
|
||||
}
|
||||
|
||||
if (!side || (side !== 'offerer' && side !== 'answerer')) {
|
||||
return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
|
||||
}
|
||||
|
||||
if (!answer && !candidate) {
|
||||
return c.json({ error: 'Missing required parameter: answer or candidate' }, 400);
|
||||
}
|
||||
|
||||
if (answer && candidate) {
|
||||
return c.json({ error: 'Cannot provide both answer and candidate' }, 400);
|
||||
}
|
||||
|
||||
const session = await storage.getSession(code, origin);
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
await storage.updateSession(code, origin, { answer });
|
||||
}
|
||||
|
||||
if (candidate) {
|
||||
if (side === 'offerer') {
|
||||
const updatedCandidates = [...session.offerCandidates, candidate];
|
||||
await storage.updateSession(code, origin, { offerCandidates: updatedCandidates });
|
||||
} else {
|
||||
const updatedCandidates = [...session.answerCandidates, candidate];
|
||||
await storage.updateSession(code, origin, { answerCandidates: updatedCandidates });
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error handling answer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /poll
|
||||
* Polls for session data (offer, answer, ICE candidates)
|
||||
* Body: { code: string, side: 'offerer' | 'answerer' }
|
||||
*/
|
||||
app.post('/poll', async (c) => {
|
||||
try {
|
||||
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
||||
const body = await c.req.json();
|
||||
const { code, side } = body;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
|
||||
}
|
||||
|
||||
if (!side || (side !== 'offerer' && side !== 'answerer')) {
|
||||
return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
|
||||
}
|
||||
|
||||
const session = await storage.getSession(code, origin);
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
|
||||
}
|
||||
|
||||
if (side === 'offerer') {
|
||||
return c.json({
|
||||
answer: session.answer || null,
|
||||
answerCandidates: session.answerCandidates,
|
||||
});
|
||||
} else {
|
||||
return c.json({
|
||||
offer: session.offer,
|
||||
offerCandidates: session.offerCandidates,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling session:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'ok', timestamp: Date.now() });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
26
src/config.ts
Normal file
26
src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Application configuration
|
||||
* Reads from environment variables with sensible defaults
|
||||
*/
|
||||
export interface Config {
|
||||
port: number;
|
||||
storageType: 'sqlite' | 'memory';
|
||||
storagePath: string;
|
||||
sessionTimeout: number;
|
||||
corsOrigins: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from environment variables
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
|
||||
storagePath: process.env.STORAGE_PATH || ':memory:',
|
||||
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '300000', 10),
|
||||
corsOrigins: process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'],
|
||||
};
|
||||
}
|
||||
59
src/index.ts
Normal file
59
src/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { SQLiteStorage } from './storage/sqlite.ts';
|
||||
import { Storage } from './storage/types.ts';
|
||||
|
||||
/**
|
||||
* Main entry point for the standalone Node.js server
|
||||
*/
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
console.log('Starting Rondevu server...');
|
||||
console.log('Configuration:', {
|
||||
port: config.port,
|
||||
storageType: config.storageType,
|
||||
storagePath: config.storagePath,
|
||||
sessionTimeout: `${config.sessionTimeout}ms`,
|
||||
corsOrigins: config.corsOrigins,
|
||||
});
|
||||
|
||||
let storage: Storage;
|
||||
|
||||
if (config.storageType === 'sqlite') {
|
||||
storage = new SQLiteStorage(config.storagePath);
|
||||
console.log('Using SQLite storage');
|
||||
} else {
|
||||
throw new Error('Unsupported storage type');
|
||||
}
|
||||
|
||||
const app = createApp(storage, {
|
||||
sessionTimeout: config.sessionTimeout,
|
||||
corsOrigins: config.corsOrigins,
|
||||
});
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
});
|
||||
|
||||
console.log(`Server running on http://localhost:${config.port}`);
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
await storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
await storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
241
src/storage/kv.ts
Normal file
241
src/storage/kv.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Storage, Session } from './types.ts';
|
||||
|
||||
/**
|
||||
* Cloudflare KV storage adapter for session management
|
||||
*/
|
||||
export class KVStorage implements Storage {
|
||||
private kv: KVNamespace;
|
||||
|
||||
/**
|
||||
* Creates a new KV storage instance
|
||||
* @param kv Cloudflare KV namespace binding
|
||||
*/
|
||||
constructor(kv: KVNamespace) {
|
||||
this.kv = kv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique code using Web Crypto API
|
||||
*/
|
||||
private generateCode(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the key for storing a session
|
||||
*/
|
||||
private sessionKey(code: string): string {
|
||||
return `session:${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the key for the topic index
|
||||
*/
|
||||
private topicIndexKey(origin: string, topic: string): string {
|
||||
return `index:${origin}:${topic}`;
|
||||
}
|
||||
|
||||
async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string> {
|
||||
// Validate info length
|
||||
if (info.length > 1024) {
|
||||
throw new Error('Info string must be 1024 characters or less');
|
||||
}
|
||||
|
||||
const code = this.generateCode();
|
||||
const createdAt = Date.now();
|
||||
|
||||
const session: Session = {
|
||||
code,
|
||||
origin,
|
||||
topic,
|
||||
info,
|
||||
offer,
|
||||
answer: undefined,
|
||||
offerCandidates: [],
|
||||
answerCandidates: [],
|
||||
createdAt,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
// Calculate TTL in seconds for KV
|
||||
const ttl = Math.max(60, Math.floor((expiresAt - createdAt) / 1000));
|
||||
|
||||
// Store the session
|
||||
await this.kv.put(
|
||||
this.sessionKey(code),
|
||||
JSON.stringify(session),
|
||||
{ expirationTtl: ttl }
|
||||
);
|
||||
|
||||
// Update the topic index
|
||||
const indexKey = this.topicIndexKey(origin, topic);
|
||||
const existingIndex = await this.kv.get(indexKey, 'json') as string[] | null;
|
||||
const updatedIndex = existingIndex ? [...existingIndex, code] : [code];
|
||||
|
||||
// Set index TTL to slightly longer than session TTL to avoid race conditions
|
||||
await this.kv.put(
|
||||
indexKey,
|
||||
JSON.stringify(updatedIndex),
|
||||
{ expirationTtl: ttl + 300 }
|
||||
);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
|
||||
const indexKey = this.topicIndexKey(origin, topic);
|
||||
const codes = await this.kv.get(indexKey, 'json') as string[] | null;
|
||||
|
||||
if (!codes || codes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all sessions in parallel
|
||||
const sessionPromises = codes.map(async (code) => {
|
||||
const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
|
||||
return sessionData;
|
||||
});
|
||||
|
||||
const sessions = await Promise.all(sessionPromises);
|
||||
|
||||
// Filter out expired or answered sessions, and null values
|
||||
const now = Date.now();
|
||||
const validSessions = sessions.filter(
|
||||
(session): session is Session =>
|
||||
session !== null &&
|
||||
session.expiresAt > now &&
|
||||
session.answer === undefined
|
||||
);
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
return validSessions.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
async listTopics(origin: string, page: number, limit: number): Promise<{
|
||||
topics: Array<{ topic: string; count: number }>;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}> {
|
||||
// Ensure limit doesn't exceed 1000
|
||||
const safeLimit = Math.min(Math.max(1, limit), 1000);
|
||||
const safePage = Math.max(1, page);
|
||||
|
||||
const prefix = `index:${origin}:`;
|
||||
const topicCounts = new Map<string, number>();
|
||||
|
||||
// List all index keys for this origin
|
||||
const list = await this.kv.list({ prefix });
|
||||
|
||||
// Process each topic index
|
||||
for (const key of list.keys) {
|
||||
// Extract topic from key: "index:{origin}:{topic}"
|
||||
const topic = key.name.substring(prefix.length);
|
||||
|
||||
// Get the session codes for this topic
|
||||
const codes = await this.kv.get(key.name, 'json') as string[] | null;
|
||||
|
||||
if (!codes || codes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch sessions to count only valid ones (unexpired and unanswered)
|
||||
const sessionPromises = codes.map(async (code) => {
|
||||
const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
|
||||
return sessionData;
|
||||
});
|
||||
|
||||
const sessions = await Promise.all(sessionPromises);
|
||||
|
||||
// Count valid sessions
|
||||
const now = Date.now();
|
||||
const validCount = sessions.filter(
|
||||
(session) =>
|
||||
session !== null &&
|
||||
session.expiresAt > now &&
|
||||
session.answer === undefined
|
||||
).length;
|
||||
|
||||
if (validCount > 0) {
|
||||
topicCounts.set(topic, validCount);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by topic name
|
||||
const allTopics = Array.from(topicCounts.entries())
|
||||
.map(([topic, count]) => ({ topic, count }))
|
||||
.sort((a, b) => a.topic.localeCompare(b.topic));
|
||||
|
||||
// Apply pagination
|
||||
const total = allTopics.length;
|
||||
const offset = (safePage - 1) * safeLimit;
|
||||
const topics = allTopics.slice(offset, offset + safeLimit);
|
||||
|
||||
return {
|
||||
topics,
|
||||
pagination: {
|
||||
page: safePage,
|
||||
limit: safeLimit,
|
||||
total,
|
||||
hasMore: offset + topics.length < total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getSession(code: string, origin: string): Promise<Session | null> {
|
||||
const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null;
|
||||
|
||||
if (!sessionData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate origin and expiration
|
||||
if (sessionData.origin !== origin || sessionData.expiresAt <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
|
||||
const current = await this.getSession(code, origin);
|
||||
|
||||
if (!current) {
|
||||
throw new Error('Session not found or origin mismatch');
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
const updated: Session = {
|
||||
...current,
|
||||
...(update.answer !== undefined && { answer: update.answer }),
|
||||
...(update.offerCandidates !== undefined && { offerCandidates: update.offerCandidates }),
|
||||
...(update.answerCandidates !== undefined && { answerCandidates: update.answerCandidates }),
|
||||
};
|
||||
|
||||
// Calculate remaining TTL
|
||||
const ttl = Math.max(60, Math.floor((updated.expiresAt - Date.now()) / 1000));
|
||||
|
||||
// Update the session
|
||||
await this.kv.put(
|
||||
this.sessionKey(code),
|
||||
JSON.stringify(updated),
|
||||
{ expirationTtl: ttl }
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSession(code: string): Promise<void> {
|
||||
await this.kv.delete(this.sessionKey(code));
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
// KV automatically expires keys based on TTL
|
||||
// No manual cleanup needed
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// No connection to close for KV
|
||||
}
|
||||
}
|
||||
258
src/storage/sqlite.ts
Normal file
258
src/storage/sqlite.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Storage, Session } from './types.ts';
|
||||
|
||||
/**
|
||||
* SQLite storage adapter for session management
|
||||
* Supports both file-based and in-memory databases
|
||||
*/
|
||||
export class SQLiteStorage implements Storage {
|
||||
private db: Database.Database;
|
||||
|
||||
/**
|
||||
* Creates a new SQLite storage instance
|
||||
* @param path Path to SQLite database file, or ':memory:' for in-memory database
|
||||
*/
|
||||
constructor(path: string = ':memory:') {
|
||||
this.db = new Database(path);
|
||||
this.initializeDatabase();
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database schema
|
||||
*/
|
||||
private initializeDatabase(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
code TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
info TEXT NOT NULL CHECK(length(info) <= 1024),
|
||||
offer TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts periodic cleanup of expired sessions
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
// Run cleanup every minute
|
||||
setInterval(() => {
|
||||
this.cleanup().catch(err => {
|
||||
console.error('Cleanup error:', err);
|
||||
});
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique code using UUID
|
||||
*/
|
||||
private generateCode(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string> {
|
||||
// Validate info length
|
||||
if (info.length > 1024) {
|
||||
throw new Error('Info string must be 1024 characters or less');
|
||||
}
|
||||
|
||||
let code: string;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
// Try to generate a unique code
|
||||
do {
|
||||
code = this.generateCode();
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
throw new Error('Failed to generate unique session code');
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
// If unique constraint failed, try again
|
||||
if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM sessions
|
||||
WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(origin, topic, Date.now()) as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
code: row.code,
|
||||
origin: row.origin,
|
||||
topic: row.topic,
|
||||
info: row.info,
|
||||
offer: row.offer,
|
||||
answer: row.answer || undefined,
|
||||
offerCandidates: JSON.parse(row.offer_candidates),
|
||||
answerCandidates: JSON.parse(row.answer_candidates),
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async listTopics(origin: string, page: number, limit: number): Promise<{
|
||||
topics: Array<{ topic: string; count: number }>;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}> {
|
||||
// Ensure limit doesn't exceed 1000
|
||||
const safeLimit = Math.min(Math.max(1, limit), 1000);
|
||||
const safePage = Math.max(1, page);
|
||||
const offset = (safePage - 1) * safeLimit;
|
||||
|
||||
// Get total count of topics
|
||||
const countStmt = this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT topic) as total
|
||||
FROM sessions
|
||||
WHERE origin = ? AND expires_at > ? AND answer IS NULL
|
||||
`);
|
||||
const { total } = countStmt.get(origin, Date.now()) as any;
|
||||
|
||||
// Get paginated topics
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT topic, COUNT(*) as count
|
||||
FROM sessions
|
||||
WHERE origin = ? AND expires_at > ? AND answer IS NULL
|
||||
GROUP BY topic
|
||||
ORDER BY topic ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(origin, Date.now(), safeLimit, offset) as any[];
|
||||
|
||||
const topics = rows.map(row => ({
|
||||
topic: row.topic,
|
||||
count: row.count,
|
||||
}));
|
||||
|
||||
return {
|
||||
topics,
|
||||
pagination: {
|
||||
page: safePage,
|
||||
limit: safeLimit,
|
||||
total,
|
||||
hasMore: offset + topics.length < total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getSession(code: string, origin: string): Promise<Session | null> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM sessions WHERE code = ? AND origin = ? AND expires_at > ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(code, origin, Date.now()) as any;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: row.code,
|
||||
origin: row.origin,
|
||||
topic: row.topic,
|
||||
info: row.info,
|
||||
offer: row.offer,
|
||||
answer: row.answer || undefined,
|
||||
offerCandidates: JSON.parse(row.offer_candidates),
|
||||
answerCandidates: JSON.parse(row.answer_candidates),
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
|
||||
const current = await this.getSession(code, origin);
|
||||
|
||||
if (!current) {
|
||||
throw new Error('Session not found or origin mismatch');
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (update.answer !== undefined) {
|
||||
updates.push('answer = ?');
|
||||
values.push(update.answer);
|
||||
}
|
||||
|
||||
if (update.offerCandidates !== undefined) {
|
||||
updates.push('offer_candidates = ?');
|
||||
values.push(JSON.stringify(update.offerCandidates));
|
||||
}
|
||||
|
||||
if (update.answerCandidates !== undefined) {
|
||||
updates.push('answer_candidates = ?');
|
||||
values.push(JSON.stringify(update.answerCandidates));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.push(code);
|
||||
values.push(origin);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE sessions SET ${updates.join(', ')} WHERE code = ? AND origin = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
}
|
||||
|
||||
async deleteSession(code: string): Promise<void> {
|
||||
const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?');
|
||||
stmt.run(code);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?');
|
||||
const result = stmt.run(Date.now());
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log(`Cleaned up ${result.changes} expired session(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
90
src/storage/types.ts
Normal file
90
src/storage/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Represents a WebRTC signaling session
|
||||
*/
|
||||
export interface Session {
|
||||
code: string;
|
||||
origin: string;
|
||||
topic: string;
|
||||
info: string;
|
||||
offer: string;
|
||||
answer?: string;
|
||||
offerCandidates: string[];
|
||||
answerCandidates: string[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage interface for session management
|
||||
* Implementations can use different backends (SQLite, Redis, Memory, etc.)
|
||||
*/
|
||||
export interface Storage {
|
||||
/**
|
||||
* Creates a new session with the given offer
|
||||
* @param origin The Origin header from the request
|
||||
* @param topic The topic to post the offer to
|
||||
* @param info User info string (max 1024 chars)
|
||||
* @param offer The WebRTC SDP offer message
|
||||
* @param expiresAt Unix timestamp when the session should expire
|
||||
* @returns The unique session code
|
||||
*/
|
||||
createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise<string>;
|
||||
|
||||
/**
|
||||
* Lists all unanswered sessions for a given origin and topic
|
||||
* @param origin The Origin header from the request
|
||||
* @param topic The topic to list offers for
|
||||
* @returns Array of sessions that haven't been answered yet
|
||||
*/
|
||||
listSessionsByTopic(origin: string, topic: string): Promise<Session[]>;
|
||||
|
||||
/**
|
||||
* Lists all topics for a given origin with their session counts
|
||||
* @param origin The Origin header from the request
|
||||
* @param page Page number (starting from 1)
|
||||
* @param limit Number of results per page (max 1000)
|
||||
* @returns Object with topics array and pagination metadata
|
||||
*/
|
||||
listTopics(origin: string, page: number, limit: number): Promise<{
|
||||
topics: Array<{ topic: string; count: number }>;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Retrieves a session by its code
|
||||
* @param code The session code
|
||||
* @param origin The Origin header from the request (for validation)
|
||||
* @returns The session if found, null otherwise
|
||||
*/
|
||||
getSession(code: string, origin: string): Promise<Session | null>;
|
||||
|
||||
/**
|
||||
* Updates an existing session with new data
|
||||
* @param code The session code
|
||||
* @param origin The Origin header from the request (for validation)
|
||||
* @param update Partial session data to update
|
||||
*/
|
||||
updateSession(code: string, origin: string, update: Partial<Session>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes a session
|
||||
* @param code The session code
|
||||
*/
|
||||
deleteSession(code: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes expired sessions
|
||||
* Should be called periodically to clean up old data
|
||||
*/
|
||||
cleanup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Closes the storage connection and releases resources
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
39
src/worker.ts
Normal file
39
src/worker.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createApp } from './app.ts';
|
||||
import { KVStorage } from './storage/kv.ts';
|
||||
|
||||
/**
|
||||
* Cloudflare Workers environment bindings
|
||||
*/
|
||||
export interface Env {
|
||||
SESSIONS: KVNamespace;
|
||||
SESSION_TIMEOUT?: string;
|
||||
CORS_ORIGINS?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers fetch handler
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Initialize KV storage
|
||||
const storage = new KVStorage(env.SESSIONS);
|
||||
|
||||
// Parse configuration
|
||||
const sessionTimeout = env.SESSION_TIMEOUT
|
||||
? parseInt(env.SESSION_TIMEOUT, 10)
|
||||
: 300000; // 5 minutes default
|
||||
|
||||
const corsOrigins = env.CORS_ORIGINS
|
||||
? env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'];
|
||||
|
||||
// Create Hono app
|
||||
const app = createApp(storage, {
|
||||
sessionTimeout,
|
||||
corsOrigins,
|
||||
});
|
||||
|
||||
// Handle request
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user