Simplify API: remove topics, rename session→offer

- Remove topic-based grouping and discovery
- Rename sessions to offers for clarity
- Simplify to just POST /offer, POST /answer, POST /poll
- Add version to health endpoint
- Update database schema (sessions→offers table)
- Reduce offer timeout to 1 minute
- Server version: 0.0.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 23:17:38 +01:00
parent 84c60320fa
commit 28ef5eb1d1
9 changed files with 143 additions and 383 deletions

View File

@@ -0,0 +1,22 @@
-- Remove topics and rename sessions to offers
-- This is a breaking change requiring a fresh database
-- Drop old sessions table
DROP TABLE IF EXISTS sessions;
-- Create offers table (without topic)
CREATE TABLE offers (
code TEXT PRIMARY KEY,
origin TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 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 indexes for efficient queries
CREATE INDEX idx_offers_expires_at ON offers(expires_at);
CREATE INDEX idx_offers_origin ON offers(origin);

View File

@@ -4,13 +4,13 @@ import { Storage } from './storage/types.ts';
import type { Context } from 'hono'; import type { Context } from 'hono';
export interface AppConfig { export interface AppConfig {
sessionTimeout: number; offerTimeout: number;
corsOrigins: string[]; corsOrigins: string[];
version?: string; version?: string;
} }
/** /**
* Determines the origin for session isolation. * Determines the origin for offer isolation.
* If X-Rondevu-Global header is set to 'true', returns the global origin (https://ronde.vu). * If X-Rondevu-Global header is set to 'true', returns the global origin (https://ronde.vu).
* Otherwise, returns the request's Origin header. * Otherwise, returns the request's Origin header.
*/ */
@@ -60,80 +60,28 @@ export function createApp(storage: Storage, config: AppConfig) {
}); });
/** /**
* GET /topics * GET /health
* Lists all topics with their unanswered session counts (paginated) * Health check endpoint with version
* Query params: page (default: 1), limit (default: 100, max: 1000)
*/ */
app.get('/topics', async (c) => { app.get('/health', (c) => {
try { return c.json({
const origin = getOrigin(c); status: 'ok',
const page = parseInt(c.req.query('page') || '1', 10); timestamp: Date.now(),
const limit = parseInt(c.req.query('limit') || '100', 10); version: config.version || 'unknown'
});
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 * POST /offer
* Lists all unanswered sessions for a topic * Creates a new offer and returns a unique code
* Body: { peerId: string, offer: string, code?: string }
*/ */
app.get('/:topic/sessions', async (c) => { app.post('/offer', async (c) => {
try { try {
const origin = getOrigin(c); const origin = getOrigin(c);
const topic = c.req.param('topic');
if (!topic) {
return c.json({ error: 'Missing required parameter: topic' }, 400);
}
if (topic.length > 1024) {
return c.json({ error: 'Topic string must be 1024 characters or less' }, 400);
}
const sessions = await storage.listSessionsByTopic(origin, topic);
return c.json({
sessions: sessions.map(s => ({
code: s.code,
peerId: s.peerId,
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: { peerId: string, offer: string }
*/
app.post('/:topic/offer', async (c) => {
try {
const origin = getOrigin(c);
const topic = c.req.param('topic');
const body = await c.req.json(); const body = await c.req.json();
const { peerId, offer, code: customCode } = body; const { peerId, offer, code: customCode } = body;
if (!topic || typeof topic !== 'string') {
return c.json({ error: 'Missing or invalid required parameter: topic' }, 400);
}
if (topic.length > 1024) {
return c.json({ error: 'Topic string must be 1024 characters or less' }, 400);
}
if (!peerId || typeof peerId !== 'string') { if (!peerId || typeof peerId !== 'string') {
return c.json({ error: 'Missing or invalid required parameter: peerId' }, 400); return c.json({ error: 'Missing or invalid required parameter: peerId' }, 400);
} }
@@ -146,14 +94,14 @@ export function createApp(storage: Storage, config: AppConfig) {
return c.json({ error: 'Missing or invalid required parameter: offer' }, 400); return c.json({ error: 'Missing or invalid required parameter: offer' }, 400);
} }
const expiresAt = Date.now() + config.sessionTimeout; const expiresAt = Date.now() + config.offerTimeout;
const code = await storage.createSession(origin, topic, peerId, offer, expiresAt, customCode); const code = await storage.createOffer(origin, peerId, offer, expiresAt, customCode);
return c.json({ code }, 200); return c.json({ code }, 200);
} catch (err) { } catch (err) {
console.error('Error creating offer:', err); console.error('Error creating offer:', err);
// Check if it's a session code clash error // Check if it's a code clash error
if (err instanceof Error && err.message.includes('already exists')) { if (err instanceof Error && err.message.includes('already exists')) {
return c.json({ error: err.message }, 409); return c.json({ error: err.message }, 409);
} }
@@ -189,23 +137,23 @@ export function createApp(storage: Storage, config: AppConfig) {
return c.json({ error: 'Cannot provide both answer and candidate' }, 400); return c.json({ error: 'Cannot provide both answer and candidate' }, 400);
} }
const session = await storage.getSession(code, origin); const offer = await storage.getOffer(code, origin);
if (!session) { if (!offer) {
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404); return c.json({ error: 'Offer not found, expired, or origin mismatch' }, 404);
} }
if (answer) { if (answer) {
await storage.updateSession(code, origin, { answer }); await storage.updateOffer(code, origin, { answer });
} }
if (candidate) { if (candidate) {
if (side === 'offerer') { if (side === 'offerer') {
const updatedCandidates = [...session.offerCandidates, candidate]; const updatedCandidates = [...offer.offerCandidates, candidate];
await storage.updateSession(code, origin, { offerCandidates: updatedCandidates }); await storage.updateOffer(code, origin, { offerCandidates: updatedCandidates });
} else { } else {
const updatedCandidates = [...session.answerCandidates, candidate]; const updatedCandidates = [...offer.answerCandidates, candidate];
await storage.updateSession(code, origin, { answerCandidates: updatedCandidates }); await storage.updateOffer(code, origin, { answerCandidates: updatedCandidates });
} }
} }
@@ -218,7 +166,7 @@ export function createApp(storage: Storage, config: AppConfig) {
/** /**
* POST /poll * POST /poll
* Polls for session data (offer, answer, ICE candidates) * Polls for offer data (offer, answer, ICE candidates)
* Body: { code: string, side: 'offerer' | 'answerer' } * Body: { code: string, side: 'offerer' | 'answerer' }
*/ */
app.post('/poll', async (c) => { app.post('/poll', async (c) => {
@@ -235,36 +183,28 @@ export function createApp(storage: Storage, config: AppConfig) {
return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400); return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
} }
const session = await storage.getSession(code, origin); const offer = await storage.getOffer(code, origin);
if (!session) { if (!offer) {
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404); return c.json({ error: 'Offer not found, expired, or origin mismatch' }, 404);
} }
if (side === 'offerer') { if (side === 'offerer') {
return c.json({ return c.json({
answer: session.answer || null, answer: offer.answer || null,
answerCandidates: session.answerCandidates, answerCandidates: offer.answerCandidates,
}); });
} else { } else {
return c.json({ return c.json({
offer: session.offer, offer: offer.offer,
offerCandidates: session.offerCandidates, offerCandidates: offer.offerCandidates,
}); });
} }
} catch (err) { } catch (err) {
console.error('Error polling session:', err); console.error('Error polling offer:', err);
return c.json({ error: 'Internal server error' }, 500); 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; return app;
} }

View File

@@ -6,8 +6,9 @@ export interface Config {
port: number; port: number;
storageType: 'sqlite' | 'memory'; storageType: 'sqlite' | 'memory';
storagePath: string; storagePath: string;
sessionTimeout: number; offerTimeout: number;
corsOrigins: string[]; corsOrigins: string[];
version: string;
} }
/** /**
@@ -18,9 +19,10 @@ export function loadConfig(): Config {
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || '3000', 10),
storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory', storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
storagePath: process.env.STORAGE_PATH || ':memory:', storagePath: process.env.STORAGE_PATH || ':memory:',
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '300000', 10), offerTimeout: parseInt(process.env.OFFER_TIMEOUT || '60000', 10),
corsOrigins: process.env.CORS_ORIGINS corsOrigins: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(o => o.trim()) ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
: ['*'], : ['*'],
version: process.env.VERSION || 'unknown',
}; };
} }

View File

@@ -15,8 +15,9 @@ async function main() {
port: config.port, port: config.port,
storageType: config.storageType, storageType: config.storageType,
storagePath: config.storagePath, storagePath: config.storagePath,
sessionTimeout: `${config.sessionTimeout}ms`, offerTimeout: `${config.offerTimeout}ms`,
corsOrigins: config.corsOrigins, corsOrigins: config.corsOrigins,
version: config.version,
}); });
let storage: Storage; let storage: Storage;
@@ -29,9 +30,9 @@ async function main() {
} }
const app = createApp(storage, { const app = createApp(storage, {
sessionTimeout: config.sessionTimeout, offerTimeout: config.offerTimeout,
corsOrigins: config.corsOrigins, corsOrigins: config.corsOrigins,
version: process.env.RONDEVU_VERSION || 'unknown', version: config.version,
}); });
const server = serve({ const server = serve({

View File

@@ -1,4 +1,4 @@
import { Storage, Session } from './types.ts'; import { Storage, Offer } from './types.ts';
// Generate a UUID v4 // Generate a UUID v4
function generateUUID(): string { function generateUUID(): string {
@@ -6,7 +6,7 @@ function generateUUID(): string {
} }
/** /**
* D1 storage adapter for session management using Cloudflare D1 * D1 storage adapter for offer management using Cloudflare D1
*/ */
export class D1Storage implements Storage { export class D1Storage implements Storage {
private db: D1Database; private db: D1Database;
@@ -25,10 +25,9 @@ export class D1Storage implements Storage {
*/ */
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
await this.db.exec(` await this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS offers (
code TEXT PRIMARY KEY, code TEXT PRIMARY KEY,
origin TEXT NOT NULL, origin TEXT NOT NULL,
topic TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
offer TEXT NOT NULL, offer TEXT NOT NULL,
answer TEXT, answer TEXT,
@@ -38,113 +37,13 @@ export class D1Storage implements Storage {
expires_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_offers_expires_at ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic); CREATE INDEX IF NOT EXISTS idx_offers_origin ON offers(origin);
CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
`); `);
} }
async listTopics(origin: string, page: number = 1, limit: number = 100): Promise<{ async createOffer(
topics: Array<{ topic: string; count: number }>;
pagination: {
page: number;
limit: number;
total: number;
hasMore: boolean;
};
}> {
// Clamp limit to maximum of 1000
const effectiveLimit = Math.min(limit, 1000);
const offset = (page - 1) * effectiveLimit;
try {
// Get total count of topics for this origin
const countResult = await this.db.prepare(`
SELECT COUNT(DISTINCT topic) as total
FROM sessions
WHERE origin = ? AND expires_at > ? AND answer IS NULL
`).bind(origin, Date.now()).first();
const total = countResult ? Number(countResult.total) : 0;
// Get paginated topics
const result = await 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 ?
`).bind(origin, Date.now(), effectiveLimit, offset).all();
// D1 returns results in the results array, or empty array if no results
if (!result.results) {
console.error('[D1] listTopics: No results property in response:', result);
return {
topics: [],
pagination: {
page,
limit: effectiveLimit,
total: 0,
hasMore: false,
},
};
}
const topics = result.results.map((row: any) => ({
topic: row.topic,
count: Number(row.count),
}));
return {
topics,
pagination: {
page,
limit: effectiveLimit,
total,
hasMore: offset + topics.length < total,
},
};
} catch (error) {
console.error('[D1] listTopics error:', error);
throw error;
}
}
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
try {
const result = await this.db.prepare(`
SELECT * FROM sessions
WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
ORDER BY created_at DESC
`).bind(origin, topic, Date.now()).all();
if (!result.results) {
console.error('[D1] listSessionsByTopic: No results property in response:', result);
return [];
}
return result.results.map((row: any) => ({
code: row.code,
origin: row.origin,
topic: row.topic,
peerId: row.peer_id,
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,
}));
} catch (error) {
console.error('[D1] listSessionsByTopic error:', error);
throw error;
}
}
async createSession(
origin: string, origin: string,
topic: string,
peerId: string, peerId: string,
offer: string, offer: string,
expiresAt: number, expiresAt: number,
@@ -160,21 +59,21 @@ export class D1Storage implements Storage {
attempts++; attempts++;
if (attempts > maxAttempts) { if (attempts > maxAttempts) {
throw new Error('Failed to generate unique session code'); throw new Error('Failed to generate unique offer code');
} }
try { try {
await this.db.prepare(` await this.db.prepare(`
INSERT INTO sessions (code, origin, topic, peer_id, offer, created_at, expires_at) INSERT INTO offers (code, origin, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).bind(code, origin, topic, peerId, offer, Date.now(), expiresAt).run(); `).bind(code, origin, peerId, offer, Date.now(), expiresAt).run();
break; break;
} catch (err: any) { } catch (err: any) {
// If unique constraint failed with custom code, throw error // If unique constraint failed with custom code, throw error
if (err.message?.includes('UNIQUE constraint failed')) { if (err.message?.includes('UNIQUE constraint failed')) {
if (customCode) { if (customCode) {
throw new Error(`Session code '${customCode}' already exists`); throw new Error(`Offer code '${customCode}' already exists`);
} }
// Try again with new generated code // Try again with new generated code
continue; continue;
@@ -186,10 +85,10 @@ export class D1Storage implements Storage {
return code; return code;
} }
async getSession(code: string, origin: string): Promise<Session | null> { async getOffer(code: string, origin: string): Promise<Offer | null> {
try { try {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT * FROM sessions SELECT * FROM offers
WHERE code = ? AND origin = ? AND expires_at > ? WHERE code = ? AND origin = ? AND expires_at > ?
`).bind(code, origin, Date.now()).first(); `).bind(code, origin, Date.now()).first();
@@ -202,7 +101,6 @@ export class D1Storage implements Storage {
return { return {
code: row.code, code: row.code,
origin: row.origin, origin: row.origin,
topic: row.topic,
peerId: row.peer_id, peerId: row.peer_id,
offer: row.offer, offer: row.offer,
answer: row.answer || undefined, answer: row.answer || undefined,
@@ -212,17 +110,17 @@ export class D1Storage implements Storage {
expiresAt: row.expires_at, expiresAt: row.expires_at,
}; };
} catch (error) { } catch (error) {
console.error('[D1] getSession error:', error); console.error('[D1] getOffer error:', error);
throw error; throw error;
} }
} }
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> { async updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void> {
// Verify session exists and origin matches // Verify offer exists and origin matches
const current = await this.getSession(code, origin); const current = await this.getOffer(code, origin);
if (!current) { if (!current) {
throw new Error('Session not found or origin mismatch'); throw new Error('Offer not found or origin mismatch');
} }
// Build update query dynamically based on what fields are being updated // Build update query dynamically based on what fields are being updated
@@ -253,7 +151,7 @@ export class D1Storage implements Storage {
// D1 provides strong consistency, so this update is atomic and immediately visible // D1 provides strong consistency, so this update is atomic and immediately visible
const query = ` const query = `
UPDATE sessions UPDATE offers
SET ${updates.join(', ')} SET ${updates.join(', ')}
WHERE code = ? AND origin = ? WHERE code = ? AND origin = ?
`; `;
@@ -261,22 +159,22 @@ export class D1Storage implements Storage {
await this.db.prepare(query).bind(...values).run(); await this.db.prepare(query).bind(...values).run();
} }
async deleteSession(code: string): Promise<void> { async deleteOffer(code: string): Promise<void> {
await this.db.prepare(` await this.db.prepare(`
DELETE FROM sessions WHERE code = ? DELETE FROM offers WHERE code = ?
`).bind(code).run(); `).bind(code).run();
} }
async cleanupExpiredSessions(): Promise<number> { async cleanupExpiredOffers(): Promise<number> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
DELETE FROM sessions WHERE expires_at <= ? DELETE FROM offers WHERE expires_at <= ?
`).bind(Date.now()).run(); `).bind(Date.now()).run();
return result.meta.changes || 0; return result.meta.changes || 0;
} }
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
await this.cleanupExpiredSessions(); await this.cleanupExpiredOffers();
} }
async close(): Promise<void> { async close(): Promise<void> {

View File

@@ -1,9 +1,9 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { Storage, Session } from './types.ts'; import { Storage, Offer } from './types.ts';
/** /**
* SQLite storage adapter for session management * SQLite storage adapter for offer management
* Supports both file-based and in-memory databases * Supports both file-based and in-memory databases
*/ */
export class SQLiteStorage implements Storage { export class SQLiteStorage implements Storage {
@@ -24,10 +24,9 @@ export class SQLiteStorage implements Storage {
*/ */
private initializeDatabase(): void { private initializeDatabase(): void {
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS offers (
code TEXT PRIMARY KEY, code TEXT PRIMARY KEY,
origin TEXT NOT NULL, origin TEXT NOT NULL,
topic TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
offer TEXT NOT NULL, offer TEXT NOT NULL,
answer TEXT, answer TEXT,
@@ -37,14 +36,13 @@ export class SQLiteStorage implements Storage {
expires_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_offers_expires_at ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic); CREATE INDEX IF NOT EXISTS idx_offers_origin ON offers(origin);
CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
`); `);
} }
/** /**
* Starts periodic cleanup of expired sessions * Starts periodic cleanup of expired offers
*/ */
private startCleanupInterval(): void { private startCleanupInterval(): void {
// Run cleanup every minute // Run cleanup every minute
@@ -62,7 +60,7 @@ export class SQLiteStorage implements Storage {
return randomUUID(); return randomUUID();
} }
async createSession(origin: string, topic: string, peerId: string, offer: string, expiresAt: number, customCode?: string): Promise<string> { async createOffer(origin: string, peerId: string, offer: string, expiresAt: number, customCode?: string): Promise<string> {
// Validate peerId length // Validate peerId length
if (peerId.length > 1024) { if (peerId.length > 1024) {
throw new Error('PeerId string must be 1024 characters or less'); throw new Error('PeerId string must be 1024 characters or less');
@@ -78,22 +76,22 @@ export class SQLiteStorage implements Storage {
attempts++; attempts++;
if (attempts > maxAttempts) { if (attempts > maxAttempts) {
throw new Error('Failed to generate unique session code'); throw new Error('Failed to generate unique offer code');
} }
try { try {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO sessions (code, origin, topic, peer_id, offer, created_at, expires_at) INSERT INTO offers (code, origin, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`); `);
stmt.run(code, origin, topic, peerId, offer, Date.now(), expiresAt); stmt.run(code, origin, peerId, offer, Date.now(), expiresAt);
break; break;
} catch (err: any) { } catch (err: any) {
// If unique constraint failed with custom code, throw error // If unique constraint failed with custom code, throw error
if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') { if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
if (customCode) { if (customCode) {
throw new Error(`Session code '${customCode}' already exists`); throw new Error(`Offer code '${customCode}' already exists`);
} }
// Try again with new generated code // Try again with new generated code
continue; continue;
@@ -105,82 +103,9 @@ export class SQLiteStorage implements Storage {
return code; return code;
} }
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> { async getOffer(code: string, origin: string): Promise<Offer | null> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT * FROM sessions SELECT * FROM offers WHERE code = ? AND origin = ? AND expires_at > ?
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,
peerId: row.peer_id,
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; const row = stmt.get(code, origin, Date.now()) as any;
@@ -192,7 +117,6 @@ export class SQLiteStorage implements Storage {
return { return {
code: row.code, code: row.code,
origin: row.origin, origin: row.origin,
topic: row.topic,
peerId: row.peer_id, peerId: row.peer_id,
offer: row.offer, offer: row.offer,
answer: row.answer || undefined, answer: row.answer || undefined,
@@ -203,11 +127,11 @@ export class SQLiteStorage implements Storage {
}; };
} }
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> { async updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void> {
const current = await this.getSession(code, origin); const current = await this.getOffer(code, origin);
if (!current) { if (!current) {
throw new Error('Session not found or origin mismatch'); throw new Error('Offer not found or origin mismatch');
} }
const updates: string[] = []; const updates: string[] = [];
@@ -236,23 +160,23 @@ export class SQLiteStorage implements Storage {
values.push(origin); values.push(origin);
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE sessions SET ${updates.join(', ')} WHERE code = ? AND origin = ? UPDATE offers SET ${updates.join(', ')} WHERE code = ? AND origin = ?
`); `);
stmt.run(...values); stmt.run(...values);
} }
async deleteSession(code: string): Promise<void> { async deleteOffer(code: string): Promise<void> {
const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?'); const stmt = this.db.prepare('DELETE FROM offers WHERE code = ?');
stmt.run(code); stmt.run(code);
} }
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?'); const stmt = this.db.prepare('DELETE FROM offers WHERE expires_at <= ?');
const result = stmt.run(Date.now()); const result = stmt.run(Date.now());
if (result.changes > 0) { if (result.changes > 0) {
console.log(`Cleaned up ${result.changes} expired session(s)`); console.log(`Cleaned up ${result.changes} expired offer(s)`);
} }
} }

View File

@@ -1,10 +1,9 @@
/** /**
* Represents a WebRTC signaling session * Represents a WebRTC signaling offer
*/ */
export interface Session { export interface Offer {
code: string; code: string;
origin: string; origin: string;
topic: string;
peerId: string; peerId: string;
offer: string; offer: string;
answer?: string; answer?: string;
@@ -15,71 +14,45 @@ export interface Session {
} }
/** /**
* Storage interface for session management * Storage interface for offer management
* Implementations can use different backends (SQLite, Redis, Memory, etc.) * Implementations can use different backends (SQLite, D1, Memory, etc.)
*/ */
export interface Storage { export interface Storage {
/** /**
* Creates a new session with the given offer * Creates a new offer
* @param origin The Origin header from the request * @param origin The Origin header from the request
* @param topic The topic to post the offer to
* @param peerId Peer identifier string (max 1024 chars) * @param peerId Peer identifier string (max 1024 chars)
* @param offer The WebRTC SDP offer message * @param offer The WebRTC SDP offer message
* @param expiresAt Unix timestamp when the session should expire * @param expiresAt Unix timestamp when the offer should expire
* @param customCode Optional custom code (if not provided, generates UUID) * @param customCode Optional custom code (if not provided, generates UUID)
* @returns The unique session code * @returns The unique offer code
*/ */
createSession(origin: string, topic: string, peerId: string, offer: string, expiresAt: number, customCode?: string): Promise<string>; createOffer(origin: string, peerId: string, offer: string, expiresAt: number, customCode?: string): Promise<string>;
/** /**
* Lists all unanswered sessions for a given origin and topic * Retrieves an offer by its code
* @param origin The Origin header from the request * @param code The offer code
* @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) * @param origin The Origin header from the request (for validation)
* @returns The session if found, null otherwise * @returns The offer if found, null otherwise
*/ */
getSession(code: string, origin: string): Promise<Session | null>; getOffer(code: string, origin: string): Promise<Offer | null>;
/** /**
* Updates an existing session with new data * Updates an existing offer with new data
* @param code The session code * @param code The offer code
* @param origin The Origin header from the request (for validation) * @param origin The Origin header from the request (for validation)
* @param update Partial session data to update * @param update Partial offer data to update
*/ */
updateSession(code: string, origin: string, update: Partial<Session>): Promise<void>; updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void>;
/** /**
* Deletes a session * Deletes an offer
* @param code The session code * @param code The offer code
*/ */
deleteSession(code: string): Promise<void>; deleteOffer(code: string): Promise<void>;
/** /**
* Removes expired sessions * Removes expired offers
* Should be called periodically to clean up old data * Should be called periodically to clean up old data
*/ */
cleanup(): Promise<void>; cleanup(): Promise<void>;

View File

@@ -6,7 +6,7 @@ import { D1Storage } from './storage/d1.ts';
*/ */
export interface Env { export interface Env {
DB: D1Database; DB: D1Database;
SESSION_TIMEOUT?: string; OFFER_TIMEOUT?: string;
CORS_ORIGINS?: string; CORS_ORIGINS?: string;
VERSION?: string; VERSION?: string;
} }
@@ -20,9 +20,9 @@ export default {
const storage = new D1Storage(env.DB); const storage = new D1Storage(env.DB);
// Parse configuration // Parse configuration
const sessionTimeout = env.SESSION_TIMEOUT const offerTimeout = env.OFFER_TIMEOUT
? parseInt(env.SESSION_TIMEOUT, 10) ? parseInt(env.OFFER_TIMEOUT, 10)
: 300000; // 5 minutes default : 60000; // 1 minute default
const corsOrigins = env.CORS_ORIGINS const corsOrigins = env.CORS_ORIGINS
? env.CORS_ORIGINS.split(',').map(o => o.trim()) ? env.CORS_ORIGINS.split(',').map(o => o.trim())
@@ -30,7 +30,7 @@ export default {
// Create Hono app // Create Hono app
const app = createApp(storage, { const app = createApp(storage, {
sessionTimeout, offerTimeout,
corsOrigins, corsOrigins,
version: env.VERSION || 'unknown', version: env.VERSION || 'unknown',
}); });
@@ -41,19 +41,19 @@ export default {
/** /**
* Scheduled handler for cron triggers * Scheduled handler for cron triggers
* Runs every 5 minutes to clean up expired sessions * Runs every minute to clean up expired offers
*/ */
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const storage = new D1Storage(env.DB); const storage = new D1Storage(env.DB);
const now = Date.now(); const now = Date.now();
try { try {
// Delete expired sessions using the storage method // Delete expired offers using the storage method
const deletedCount = await storage.cleanupExpiredSessions(); const deletedCount = await storage.cleanupExpiredOffers();
console.log(`Cleaned up ${deletedCount} expired sessions at ${new Date(now).toISOString()}`); console.log(`Cleaned up ${deletedCount} expired offers at ${new Date(now).toISOString()}`);
} catch (error) { } catch (error) {
console.error('Error cleaning up sessions:', error); console.error('Error cleaning up offers:', error);
} }
}, },
}; };

View File

@@ -5,14 +5,14 @@ compatibility_date = "2024-01-01"
# D1 Database binding # D1 Database binding
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "rondevu-sessions" database_name = "rondevu-offers"
database_id = "b94e3f71-816d-455b-a89d-927fa49532d0" database_id = "b94e3f71-816d-455b-a89d-927fa49532d0"
# Environment variables # Environment variables
[vars] [vars]
SESSION_TIMEOUT = "60000" # 1 minute in milliseconds OFFER_TIMEOUT = "60000" # 1 minute in milliseconds
CORS_ORIGINS = "*" # Comma-separated list of allowed origins CORS_ORIGINS = "*" # Comma-separated list of allowed origins
VERSION = "unknown" # Set to git commit hash before deploying VERSION = "0.0.1" # Semantic version
# Build configuration # Build configuration
[build] [build]