Files
rondevu-server/src/app.ts
Bas van den Aakster 8cb56142cd Fix Cloudflare Worker compatibility for version endpoint
- Add version parameter to AppConfig interface
- Pass version from environment config instead of using process.env
- Update worker.ts to pass VERSION environment variable
- Update wrangler.toml with VERSION variable
- Update deploy script to automatically set VERSION to git commit hash

This fixes the 'process is not defined' error in Cloudflare Workers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 11:45:41 +01:00

265 lines
7.8 KiB
TypeScript

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts';
import type { Context } from 'hono';
export interface AppConfig {
sessionTimeout: number;
corsOrigins: string[];
version?: string;
}
/**
* Determines the origin for session isolation.
* If X-Rondevu-Global header is set to 'true', returns the global origin (https://ronde.vu).
* Otherwise, returns the request's Origin header.
*/
function getOrigin(c: Context): string {
const globalHeader = c.req.header('X-Rondevu-Global');
if (globalHeader === 'true') {
return 'https://ronde.vu';
}
return c.req.header('Origin') || c.req.header('origin') || 'unknown';
}
/**
* Creates the Hono application with WebRTC signaling endpoints
*/
export function createApp(storage: Storage, config: AppConfig) {
const app = new Hono();
// Enable CORS with dynamic origin handling
app.use('/*', cors({
origin: (origin) => {
// If no origin restrictions (wildcard), allow any origin
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
return origin;
}
// Otherwise check if origin is in allowed list
if (config.corsOrigins.includes(origin)) {
return origin;
}
// Default to first allowed origin
return config.corsOrigins[0];
},
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Origin', 'X-Rondevu-Global'],
exposeHeaders: ['Content-Type'],
maxAge: 600,
credentials: true,
}));
/**
* GET /
* Returns server version information
*/
app.get('/', (c) => {
return c.json({
version: config.version || 'unknown'
});
});
/**
* GET /topics
* Lists all topics with their unanswered session counts (paginated)
* Query params: page (default: 1), limit (default: 100, max: 1000)
*/
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);
}
});
/**
* GET /:topic/sessions
* Lists all unanswered sessions for a topic
*/
app.get('/:topic/sessions', 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);
}
if (peerId.length > 1024) {
return c.json({ error: 'PeerId 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, peerId, offer, expiresAt, customCode);
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 = getOrigin(c);
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 = getOrigin(c);
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;
}