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';
export interface AppConfig {
sessionTimeout: number;
offerTimeout: number;
corsOrigins: 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).
* Otherwise, returns the request's Origin header.
*/
@@ -60,80 +60,28 @@ export function createApp(storage: Storage, config: AppConfig) {
});
/**
* GET /topics
* Lists all topics with their unanswered session counts (paginated)
* Query params: page (default: 1), limit (default: 100, max: 1000)
* GET /health
* Health check endpoint with version
*/
app.get('/topics', async (c) => {
try {
const origin = getOrigin(c);
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);
}
app.get('/health', (c) => {
return c.json({
status: 'ok',
timestamp: Date.now(),
version: config.version || 'unknown'
});
});
/**
* GET /:topic/sessions
* Lists all unanswered sessions for a topic
* POST /offer
* 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 {
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 { 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') {
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);
}
const expiresAt = Date.now() + config.sessionTimeout;
const code = await storage.createSession(origin, topic, peerId, offer, expiresAt, customCode);
const expiresAt = Date.now() + config.offerTimeout;
const code = await storage.createOffer(origin, peerId, offer, expiresAt, customCode);
return c.json({ code }, 200);
} catch (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')) {
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);
}
const session = await storage.getSession(code, origin);
const offer = await storage.getOffer(code, origin);
if (!session) {
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
if (!offer) {
return c.json({ error: 'Offer not found, expired, or origin mismatch' }, 404);
}
if (answer) {
await storage.updateSession(code, origin, { answer });
await storage.updateOffer(code, origin, { answer });
}
if (candidate) {
if (side === 'offerer') {
const updatedCandidates = [...session.offerCandidates, candidate];
await storage.updateSession(code, origin, { offerCandidates: updatedCandidates });
const updatedCandidates = [...offer.offerCandidates, candidate];
await storage.updateOffer(code, origin, { offerCandidates: updatedCandidates });
} else {
const updatedCandidates = [...session.answerCandidates, candidate];
await storage.updateSession(code, origin, { answerCandidates: updatedCandidates });
const updatedCandidates = [...offer.answerCandidates, candidate];
await storage.updateOffer(code, origin, { answerCandidates: updatedCandidates });
}
}
@@ -218,7 +166,7 @@ export function createApp(storage: Storage, config: AppConfig) {
/**
* POST /poll
* Polls for session data (offer, answer, ICE candidates)
* Polls for offer data (offer, answer, ICE candidates)
* Body: { code: string, side: 'offerer' | 'answerer' }
*/
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);
}
const session = await storage.getSession(code, origin);
const offer = await storage.getOffer(code, origin);
if (!session) {
return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
if (!offer) {
return c.json({ error: 'Offer not found, expired, or origin mismatch' }, 404);
}
if (side === 'offerer') {
return c.json({
answer: session.answer || null,
answerCandidates: session.answerCandidates,
answer: offer.answer || null,
answerCandidates: offer.answerCandidates,
});
} else {
return c.json({
offer: session.offer,
offerCandidates: session.offerCandidates,
offer: offer.offer,
offerCandidates: offer.offerCandidates,
});
}
} catch (err) {
console.error('Error polling session:', err);
console.error('Error polling offer:', 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;
}

View File

@@ -6,8 +6,9 @@ export interface Config {
port: number;
storageType: 'sqlite' | 'memory';
storagePath: string;
sessionTimeout: number;
offerTimeout: number;
corsOrigins: string[];
version: string;
}
/**
@@ -18,9 +19,10 @@ export function loadConfig(): Config {
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),
offerTimeout: parseInt(process.env.OFFER_TIMEOUT || '60000', 10),
corsOrigins: process.env.CORS_ORIGINS
? 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,
storageType: config.storageType,
storagePath: config.storagePath,
sessionTimeout: `${config.sessionTimeout}ms`,
offerTimeout: `${config.offerTimeout}ms`,
corsOrigins: config.corsOrigins,
version: config.version,
});
let storage: Storage;
@@ -29,9 +30,9 @@ async function main() {
}
const app = createApp(storage, {
sessionTimeout: config.sessionTimeout,
offerTimeout: config.offerTimeout,
corsOrigins: config.corsOrigins,
version: process.env.RONDEVU_VERSION || 'unknown',
version: config.version,
});
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
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 {
private db: D1Database;
@@ -25,10 +25,9 @@ export class D1Storage implements Storage {
*/
async initializeDatabase(): Promise<void> {
await this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
CREATE TABLE IF NOT EXISTS offers (
code TEXT PRIMARY KEY,
origin TEXT NOT NULL,
topic TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
offer TEXT NOT NULL,
answer TEXT,
@@ -38,113 +37,13 @@ export class D1Storage implements Storage {
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);
CREATE INDEX IF NOT EXISTS idx_offers_expires_at ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_origin ON offers(origin);
`);
}
async listTopics(origin: string, page: number = 1, limit: number = 100): Promise<{
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(
async createOffer(
origin: string,
topic: string,
peerId: string,
offer: string,
expiresAt: number,
@@ -160,21 +59,21 @@ export class D1Storage implements Storage {
attempts++;
if (attempts > maxAttempts) {
throw new Error('Failed to generate unique session code');
throw new Error('Failed to generate unique offer code');
}
try {
await this.db.prepare(`
INSERT INTO sessions (code, origin, topic, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(code, origin, topic, peerId, offer, Date.now(), expiresAt).run();
INSERT INTO offers (code, origin, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(code, origin, peerId, offer, Date.now(), expiresAt).run();
break;
} catch (err: any) {
// If unique constraint failed with custom code, throw error
if (err.message?.includes('UNIQUE constraint failed')) {
if (customCode) {
throw new Error(`Session code '${customCode}' already exists`);
throw new Error(`Offer code '${customCode}' already exists`);
}
// Try again with new generated code
continue;
@@ -186,10 +85,10 @@ export class D1Storage implements Storage {
return code;
}
async getSession(code: string, origin: string): Promise<Session | null> {
async getOffer(code: string, origin: string): Promise<Offer | null> {
try {
const result = await this.db.prepare(`
SELECT * FROM sessions
SELECT * FROM offers
WHERE code = ? AND origin = ? AND expires_at > ?
`).bind(code, origin, Date.now()).first();
@@ -202,7 +101,6 @@ export class D1Storage implements Storage {
return {
code: row.code,
origin: row.origin,
topic: row.topic,
peerId: row.peer_id,
offer: row.offer,
answer: row.answer || undefined,
@@ -212,17 +110,17 @@ export class D1Storage implements Storage {
expiresAt: row.expires_at,
};
} catch (error) {
console.error('[D1] getSession error:', error);
console.error('[D1] getOffer error:', error);
throw error;
}
}
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
// Verify session exists and origin matches
const current = await this.getSession(code, origin);
async updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void> {
// Verify offer exists and origin matches
const current = await this.getOffer(code, origin);
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
@@ -253,7 +151,7 @@ export class D1Storage implements Storage {
// D1 provides strong consistency, so this update is atomic and immediately visible
const query = `
UPDATE sessions
UPDATE offers
SET ${updates.join(', ')}
WHERE code = ? AND origin = ?
`;
@@ -261,22 +159,22 @@ export class D1Storage implements Storage {
await this.db.prepare(query).bind(...values).run();
}
async deleteSession(code: string): Promise<void> {
async deleteOffer(code: string): Promise<void> {
await this.db.prepare(`
DELETE FROM sessions WHERE code = ?
DELETE FROM offers WHERE code = ?
`).bind(code).run();
}
async cleanupExpiredSessions(): Promise<number> {
async cleanupExpiredOffers(): Promise<number> {
const result = await this.db.prepare(`
DELETE FROM sessions WHERE expires_at <= ?
DELETE FROM offers WHERE expires_at <= ?
`).bind(Date.now()).run();
return result.meta.changes || 0;
}
async cleanup(): Promise<void> {
await this.cleanupExpiredSessions();
await this.cleanupExpiredOffers();
}
async close(): Promise<void> {

View File

@@ -1,9 +1,9 @@
import Database from 'better-sqlite3';
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
*/
export class SQLiteStorage implements Storage {
@@ -24,10 +24,9 @@ export class SQLiteStorage implements Storage {
*/
private initializeDatabase(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
CREATE TABLE IF NOT EXISTS offers (
code TEXT PRIMARY KEY,
origin TEXT NOT NULL,
topic TEXT NOT NULL,
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
offer TEXT NOT NULL,
answer TEXT,
@@ -37,14 +36,13 @@ export class SQLiteStorage implements Storage {
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);
CREATE INDEX IF NOT EXISTS idx_offers_expires_at ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_origin ON offers(origin);
`);
}
/**
* Starts periodic cleanup of expired sessions
* Starts periodic cleanup of expired offers
*/
private startCleanupInterval(): void {
// Run cleanup every minute
@@ -62,7 +60,7 @@ export class SQLiteStorage implements Storage {
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
if (peerId.length > 1024) {
throw new Error('PeerId string must be 1024 characters or less');
@@ -78,22 +76,22 @@ export class SQLiteStorage implements Storage {
attempts++;
if (attempts > maxAttempts) {
throw new Error('Failed to generate unique session code');
throw new Error('Failed to generate unique offer code');
}
try {
const stmt = this.db.prepare(`
INSERT INTO sessions (code, origin, topic, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO offers (code, origin, peer_id, offer, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(code, origin, topic, peerId, offer, Date.now(), expiresAt);
stmt.run(code, origin, peerId, offer, Date.now(), expiresAt);
break;
} catch (err: any) {
// If unique constraint failed with custom code, throw error
if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
if (customCode) {
throw new Error(`Session code '${customCode}' already exists`);
throw new Error(`Offer code '${customCode}' already exists`);
}
// Try again with new generated code
continue;
@@ -105,82 +103,9 @@ export class SQLiteStorage implements Storage {
return code;
}
async listSessionsByTopic(origin: string, topic: string): Promise<Session[]> {
async getOffer(code: string, origin: string): Promise<Offer | null> {
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,
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 > ?
SELECT * FROM offers WHERE code = ? AND origin = ? AND expires_at > ?
`);
const row = stmt.get(code, origin, Date.now()) as any;
@@ -192,7 +117,6 @@ export class SQLiteStorage implements Storage {
return {
code: row.code,
origin: row.origin,
topic: row.topic,
peerId: row.peer_id,
offer: row.offer,
answer: row.answer || undefined,
@@ -203,11 +127,11 @@ export class SQLiteStorage implements Storage {
};
}
async updateSession(code: string, origin: string, update: Partial<Session>): Promise<void> {
const current = await this.getSession(code, origin);
async updateOffer(code: string, origin: string, update: Partial<Offer>): Promise<void> {
const current = await this.getOffer(code, origin);
if (!current) {
throw new Error('Session not found or origin mismatch');
throw new Error('Offer not found or origin mismatch');
}
const updates: string[] = [];
@@ -236,23 +160,23 @@ export class SQLiteStorage implements Storage {
values.push(origin);
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);
}
async deleteSession(code: string): Promise<void> {
const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?');
async deleteOffer(code: string): Promise<void> {
const stmt = this.db.prepare('DELETE FROM offers WHERE code = ?');
stmt.run(code);
}
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());
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;
origin: string;
topic: string;
peerId: string;
offer: string;
answer?: string;
@@ -15,71 +14,45 @@ export interface Session {
}
/**
* Storage interface for session management
* Implementations can use different backends (SQLite, Redis, Memory, etc.)
* Storage interface for offer management
* Implementations can use different backends (SQLite, D1, Memory, etc.)
*/
export interface Storage {
/**
* Creates a new session with the given offer
* Creates a new offer
* @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 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)
* @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
* @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
* Retrieves an offer by its code
* @param code The offer code
* @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
* @param code The session code
* Updates an existing offer with new data
* @param code The offer code
* @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
* @param code The session code
* Deletes an offer
* @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
*/
cleanup(): Promise<void>;

View File

@@ -6,7 +6,7 @@ import { D1Storage } from './storage/d1.ts';
*/
export interface Env {
DB: D1Database;
SESSION_TIMEOUT?: string;
OFFER_TIMEOUT?: string;
CORS_ORIGINS?: string;
VERSION?: string;
}
@@ -20,9 +20,9 @@ export default {
const storage = new D1Storage(env.DB);
// Parse configuration
const sessionTimeout = env.SESSION_TIMEOUT
? parseInt(env.SESSION_TIMEOUT, 10)
: 300000; // 5 minutes default
const offerTimeout = env.OFFER_TIMEOUT
? parseInt(env.OFFER_TIMEOUT, 10)
: 60000; // 1 minute default
const corsOrigins = env.CORS_ORIGINS
? env.CORS_ORIGINS.split(',').map(o => o.trim())
@@ -30,7 +30,7 @@ export default {
// Create Hono app
const app = createApp(storage, {
sessionTimeout,
offerTimeout,
corsOrigins,
version: env.VERSION || 'unknown',
});
@@ -41,19 +41,19 @@ export default {
/**
* 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> {
const storage = new D1Storage(env.DB);
const now = Date.now();
try {
// Delete expired sessions using the storage method
const deletedCount = await storage.cleanupExpiredSessions();
// Delete expired offers using the storage method
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) {
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_databases]]
binding = "DB"
database_name = "rondevu-sessions"
database_name = "rondevu-offers"
database_id = "b94e3f71-816d-455b-a89d-927fa49532d0"
# Environment variables
[vars]
SESSION_TIMEOUT = "60000" # 1 minute in milliseconds
OFFER_TIMEOUT = "60000" # 1 minute in milliseconds
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]