mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-10 10:53:24 +00:00
feat: Implement content-based offer IDs with SHA-256 hashing
- Added hash-id.ts utility for SHA-256 content hashing
- Offer IDs now generated from hash of {sdp, topics} (sorted)
- Removed peerId from hash (inferred from authentication)
- Server generates deterministic IDs for idempotent offer creation
- Updated SQLite and D1 storage implementations
- Removed optional id field from CreateOfferRequest
- Same offer content always produces same ID
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
529
src/app.ts
Normal file
529
src/app.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Storage } from './storage/types.ts';
|
||||
import { Config } from './config.ts';
|
||||
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
|
||||
import { generatePeerId, encryptPeerId } from './crypto.ts';
|
||||
import { parseBloomFilter } from './bloom.ts';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
/**
|
||||
* Creates the Hono application with topic-based WebRTC signaling endpoints
|
||||
*/
|
||||
export function createApp(storage: Storage, config: Config) {
|
||||
const app = new Hono();
|
||||
|
||||
// Create auth middleware
|
||||
const authMiddleware = createAuthMiddleware(config.authSecret);
|
||||
|
||||
// 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', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
|
||||
exposeHeaders: ['Content-Type'],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Returns server version information
|
||||
*/
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
version: config.version,
|
||||
name: 'Rondevu',
|
||||
description: 'Topic-based peer discovery and signaling server'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint with version
|
||||
*/
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
version: config.version
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /register
|
||||
* Register a new peer and receive credentials
|
||||
*/
|
||||
app.post('/register', async (c) => {
|
||||
try {
|
||||
// Generate new peer ID
|
||||
const peerId = generatePeerId();
|
||||
|
||||
// Encrypt peer ID with server secret (async operation)
|
||||
const secret = await encryptPeerId(peerId, config.authSecret);
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
secret
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error registering peer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers
|
||||
* Creates one or more offers with topics
|
||||
* Requires authentication
|
||||
*/
|
||||
app.post('/offers', authMiddleware, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { offers } = body;
|
||||
|
||||
if (!Array.isArray(offers) || offers.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
||||
}
|
||||
|
||||
if (offers.length > config.maxOffersPerRequest) {
|
||||
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
|
||||
}
|
||||
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Validate and prepare offers
|
||||
const offerRequests = [];
|
||||
for (const offer of offers) {
|
||||
// Validate SDP
|
||||
if (!offer.sdp || typeof offer.sdp !== 'string') {
|
||||
return c.json({ error: 'Each offer must have an sdp field' }, 400);
|
||||
}
|
||||
|
||||
if (offer.sdp.length > 65536) {
|
||||
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
||||
}
|
||||
|
||||
// Validate topics
|
||||
if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
|
||||
return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
|
||||
}
|
||||
|
||||
if (offer.topics.length > config.maxTopicsPerOffer) {
|
||||
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
|
||||
}
|
||||
|
||||
for (const topic of offer.topics) {
|
||||
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
|
||||
return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clamp TTL
|
||||
let ttl = offer.ttl || config.offerDefaultTtl;
|
||||
if (ttl < config.offerMinTtl) {
|
||||
ttl = config.offerMinTtl;
|
||||
}
|
||||
if (ttl > config.offerMaxTtl) {
|
||||
ttl = config.offerMaxTtl;
|
||||
}
|
||||
|
||||
offerRequests.push({
|
||||
id: offer.id,
|
||||
peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// Create offers
|
||||
const createdOffers = await storage.createOffers(offerRequests);
|
||||
|
||||
// Return simplified response
|
||||
return c.json({
|
||||
offers: createdOffers.map(o => ({
|
||||
id: o.id,
|
||||
peerId: o.peerId,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error creating offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/by-topic/:topic
|
||||
* Find offers by topic with optional bloom filter exclusion
|
||||
* Public endpoint (no auth required)
|
||||
*/
|
||||
app.get('/offers/by-topic/:topic', async (c) => {
|
||||
try {
|
||||
const topic = c.req.param('topic');
|
||||
const bloomParam = c.req.query('bloom');
|
||||
const limitParam = c.req.query('limit');
|
||||
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
||||
|
||||
// Parse bloom filter if provided
|
||||
let excludePeerIds: string[] = [];
|
||||
if (bloomParam) {
|
||||
const bloom = parseBloomFilter(bloomParam);
|
||||
if (!bloom) {
|
||||
return c.json({ error: 'Invalid bloom filter format' }, 400);
|
||||
}
|
||||
|
||||
// Get all offers for topic first
|
||||
const allOffers = await storage.getOffersByTopic(topic);
|
||||
|
||||
// Test each peer ID against bloom filter
|
||||
const excludeSet = new Set<string>();
|
||||
for (const offer of allOffers) {
|
||||
if (bloom.test(offer.peerId)) {
|
||||
excludeSet.add(offer.peerId);
|
||||
}
|
||||
}
|
||||
|
||||
excludePeerIds = Array.from(excludeSet);
|
||||
}
|
||||
|
||||
// Get filtered offers
|
||||
let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
|
||||
|
||||
// Apply limit
|
||||
const total = offers.length;
|
||||
offers = offers.slice(0, limit);
|
||||
|
||||
return c.json({
|
||||
topic,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
peerId: o.peerId,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen
|
||||
})),
|
||||
total: bloomParam ? total + excludePeerIds.length : total,
|
||||
returned: offers.length
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching offers by topic:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /topics
|
||||
* List all topics with active peer counts (paginated)
|
||||
* Public endpoint (no auth required)
|
||||
*/
|
||||
app.get('/topics', async (c) => {
|
||||
try {
|
||||
const limitParam = c.req.query('limit');
|
||||
const offsetParam = c.req.query('offset');
|
||||
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
||||
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
||||
|
||||
const result = await storage.getTopics(limit, offset);
|
||||
|
||||
return c.json({
|
||||
topics: result.topics,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching topics:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /peers/:peerId/offers
|
||||
* View all offers from a specific peer
|
||||
* Public endpoint
|
||||
*/
|
||||
app.get('/peers/:peerId/offers', async (c) => {
|
||||
try {
|
||||
const peerId = c.req.param('peerId');
|
||||
const offers = await storage.getOffersByPeerId(peerId);
|
||||
|
||||
// Collect unique topics
|
||||
const topicsSet = new Set<string>();
|
||||
offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen
|
||||
})),
|
||||
topics: Array.from(topicsSet)
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching peer offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/mine
|
||||
* List all offers owned by authenticated peer
|
||||
* Requires authentication
|
||||
*/
|
||||
app.get('/offers/mine', authMiddleware, async (c) => {
|
||||
try {
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const offers = await storage.getOffersByPeerId(peerId);
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
createdAt: o.createdAt,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen,
|
||||
answererPeerId: o.answererPeerId,
|
||||
answeredAt: o.answeredAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching own offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /offers/:offerId/heartbeat
|
||||
* Update last_seen timestamp for an offer
|
||||
* Requires authentication and ownership
|
||||
*/
|
||||
app.put('/offers/:offerId/heartbeat', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Verify ownership
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
if (offer.peerId !== peerId) {
|
||||
return c.json({ error: 'Not authorized to update this offer' }, 403);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await storage.updateOfferLastSeen(offerId, now);
|
||||
|
||||
return c.json({
|
||||
id: offerId,
|
||||
lastSeen: now
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error updating offer heartbeat:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /offers/:offerId
|
||||
* Delete a specific offer
|
||||
* Requires authentication and ownership
|
||||
*/
|
||||
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
const deleted = await storage.deleteOffer(offerId, peerId);
|
||||
|
||||
if (!deleted) {
|
||||
return c.json({ error: 'Offer not found or not authorized' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ deleted: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error deleting offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/answer
|
||||
* Answer a specific offer (locks it to answerer)
|
||||
* Requires authentication
|
||||
*/
|
||||
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const body = await c.req.json();
|
||||
const { sdp } = body;
|
||||
|
||||
if (!sdp || typeof sdp !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
|
||||
}
|
||||
|
||||
if (sdp.length > 65536) {
|
||||
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
||||
}
|
||||
|
||||
const result = await storage.answerOffer(offerId, peerId, sdp);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
answererId: peerId,
|
||||
answeredAt: Date.now()
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error answering offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/answers
|
||||
* Poll for answers to all of authenticated peer's offers
|
||||
* Requires authentication (offerer)
|
||||
*/
|
||||
app.get('/offers/answers', authMiddleware, async (c) => {
|
||||
try {
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const offers = await storage.getAnsweredOffers(peerId);
|
||||
|
||||
return c.json({
|
||||
answers: offers.map(o => ({
|
||||
offerId: o.id,
|
||||
answererId: o.answererPeerId,
|
||||
sdp: o.answerSdp,
|
||||
answeredAt: o.answeredAt,
|
||||
topics: o.topics
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching answers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/ice-candidates
|
||||
* Post ICE candidates for an offer
|
||||
* Requires authentication (must be offerer or answerer)
|
||||
*/
|
||||
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const body = await c.req.json();
|
||||
const { candidates } = body;
|
||||
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
|
||||
}
|
||||
|
||||
// Verify offer exists and caller is offerer or answerer
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
let role: 'offerer' | 'answerer';
|
||||
if (offer.peerId === peerId) {
|
||||
role = 'offerer';
|
||||
} else if (offer.answererPeerId === peerId) {
|
||||
role = 'answerer';
|
||||
} else {
|
||||
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
|
||||
}
|
||||
|
||||
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
candidatesAdded: added
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error adding ICE candidates:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/:offerId/ice-candidates
|
||||
* Poll for ICE candidates from the other peer
|
||||
* Requires authentication (must be offerer or answerer)
|
||||
*/
|
||||
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const sinceParam = c.req.query('since');
|
||||
|
||||
const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
|
||||
|
||||
// Verify offer exists and caller is offerer or answerer
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
let targetRole: 'offerer' | 'answerer';
|
||||
if (offer.peerId === peerId) {
|
||||
// Offerer wants answerer's candidates
|
||||
targetRole = 'answerer';
|
||||
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
|
||||
} else if (offer.answererPeerId === peerId) {
|
||||
// Answerer wants offerer's candidates
|
||||
targetRole = 'offerer';
|
||||
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
|
||||
} else {
|
||||
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
|
||||
}
|
||||
|
||||
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
|
||||
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
candidates: candidates.map(c => ({
|
||||
candidate: c.candidate,
|
||||
peerId: c.peerId,
|
||||
role: c.role,
|
||||
createdAt: c.createdAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching ICE candidates:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
66
src/bloom.ts
Normal file
66
src/bloom.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Bloom filter utility for testing if peer IDs might be in a set
|
||||
* Used to filter out known peers from discovery results
|
||||
*/
|
||||
|
||||
export class BloomFilter {
|
||||
private bits: Uint8Array;
|
||||
private size: number;
|
||||
private numHashes: number;
|
||||
|
||||
/**
|
||||
* Creates a bloom filter from a base64 encoded bit array
|
||||
*/
|
||||
constructor(base64Data: string, numHashes: number = 3) {
|
||||
// Decode base64 to Uint8Array (works in both Node.js and Workers)
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
this.bits = bytes;
|
||||
this.size = this.bits.length * 8;
|
||||
this.numHashes = numHashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a peer ID might be in the filter
|
||||
* Returns true if possibly in set, false if definitely not in set
|
||||
*/
|
||||
test(peerId: string): boolean {
|
||||
for (let i = 0; i < this.numHashes; i++) {
|
||||
const hash = this.hash(peerId, i);
|
||||
const index = hash % this.size;
|
||||
const byteIndex = Math.floor(index / 8);
|
||||
const bitIndex = index % 8;
|
||||
|
||||
if (!(this.bits[byteIndex] & (1 << bitIndex))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function (FNV-1a variant)
|
||||
*/
|
||||
private hash(str: string, seed: number): number {
|
||||
let hash = 2166136261 ^ seed;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse bloom filter from base64 string
|
||||
*/
|
||||
export function parseBloomFilter(base64: string): BloomFilter | null {
|
||||
try {
|
||||
return new BloomFilter(base64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
51
src/config.ts
Normal file
51
src/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { generateSecretKey } from './crypto.ts';
|
||||
|
||||
/**
|
||||
* Application configuration
|
||||
* Reads from environment variables with sensible defaults
|
||||
*/
|
||||
export interface Config {
|
||||
port: number;
|
||||
storageType: 'sqlite' | 'memory';
|
||||
storagePath: string;
|
||||
corsOrigins: string[];
|
||||
version: string;
|
||||
authSecret: string;
|
||||
offerDefaultTtl: number;
|
||||
offerMaxTtl: number;
|
||||
offerMinTtl: number;
|
||||
cleanupInterval: number;
|
||||
maxOffersPerRequest: number;
|
||||
maxTopicsPerOffer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from environment variables
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
// Generate or load auth secret
|
||||
let authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) {
|
||||
authSecret = generateSecretKey();
|
||||
console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret);
|
||||
console.warn('All peer credentials will be invalidated on server restart.');
|
||||
console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.');
|
||||
}
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
|
||||
storagePath: process.env.STORAGE_PATH || ':memory:',
|
||||
corsOrigins: process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'],
|
||||
version: process.env.VERSION || 'unknown',
|
||||
authSecret,
|
||||
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
|
||||
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
|
||||
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
|
||||
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
|
||||
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10),
|
||||
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
|
||||
};
|
||||
}
|
||||
149
src/crypto.ts
Normal file
149
src/crypto.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Crypto utilities for stateless peer authentication
|
||||
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'AES-GCM';
|
||||
const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
|
||||
/**
|
||||
* Generates a random peer ID (16 bytes = 32 hex chars)
|
||||
*/
|
||||
export function generatePeerId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random secret key for encryption (32 bytes = 64 hex chars)
|
||||
*/
|
||||
export function generateSecretKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, (byte) =>
|
||||
String.fromCodePoint(byte)
|
||||
).join('');
|
||||
return btoa(binString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Uint8Array
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binString = atob(base64);
|
||||
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a peer ID using the server secret key
|
||||
* Returns base64-encoded encrypted data (IV + ciphertext)
|
||||
*/
|
||||
export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
|
||||
const keyBytes = hexToBytes(secretKeyHex);
|
||||
|
||||
if (keyBytes.length !== KEY_LENGTH) {
|
||||
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
||||
}
|
||||
|
||||
// Import key
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Generate random IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
|
||||
// Encrypt peer ID
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(peerId);
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv },
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Combine IV + ciphertext and encode as base64
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
return bytesToBase64(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an encrypted peer ID secret
|
||||
* Returns the plaintext peer ID or throws if decryption fails
|
||||
*/
|
||||
export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise<string> {
|
||||
try {
|
||||
const keyBytes = hexToBytes(secretKeyHex);
|
||||
|
||||
if (keyBytes.length !== KEY_LENGTH) {
|
||||
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const combined = base64ToBytes(encryptedSecret);
|
||||
|
||||
// Extract IV and ciphertext
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const ciphertext = combined.slice(IV_LENGTH);
|
||||
|
||||
// Import key
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (err) {
|
||||
throw new Error('Failed to decrypt peer ID: invalid secret or secret key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a peer ID and secret match
|
||||
* Returns true if valid, false otherwise
|
||||
*/
|
||||
export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise<boolean> {
|
||||
try {
|
||||
const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
|
||||
return decryptedPeerId === peerId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
75
src/index.ts
Normal file
75
src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
offerDefaultTtl: `${config.offerDefaultTtl}ms`,
|
||||
offerMaxTtl: `${config.offerMaxTtl}ms`,
|
||||
offerMinTtl: `${config.offerMinTtl}ms`,
|
||||
cleanupInterval: `${config.cleanupInterval}ms`,
|
||||
maxOffersPerRequest: config.maxOffersPerRequest,
|
||||
maxTopicsPerOffer: config.maxTopicsPerOffer,
|
||||
corsOrigins: config.corsOrigins,
|
||||
version: config.version,
|
||||
});
|
||||
|
||||
let storage: Storage;
|
||||
|
||||
if (config.storageType === 'sqlite') {
|
||||
storage = new SQLiteStorage(config.storagePath);
|
||||
console.log('Using SQLite storage');
|
||||
} else {
|
||||
throw new Error('Unsupported storage type');
|
||||
}
|
||||
|
||||
// Start periodic cleanup of expired offers
|
||||
const cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const deleted = await storage.deleteExpiredOffers(now);
|
||||
if (deleted > 0) {
|
||||
console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cleanup error:', err);
|
||||
}
|
||||
}, config.cleanupInterval);
|
||||
|
||||
const app = createApp(storage, config);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
});
|
||||
|
||||
console.log(`Server running on http://localhost:${config.port}`);
|
||||
console.log('Ready to accept connections');
|
||||
|
||||
// Graceful shutdown handler
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
clearInterval(cleanupInterval);
|
||||
await storage.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
51
src/middleware/auth.ts
Normal file
51
src/middleware/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { validateCredentials } from '../crypto.ts';
|
||||
|
||||
/**
|
||||
* Authentication middleware for Rondevu
|
||||
* Validates Bearer token in format: {peerId}:{encryptedSecret}
|
||||
*/
|
||||
export function createAuthMiddleware(authSecret: string) {
|
||||
return async (c: Context, next: Next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
// Expect format: Bearer {peerId}:{secret}
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401);
|
||||
}
|
||||
|
||||
const credentials = parts[1].split(':');
|
||||
if (credentials.length !== 2) {
|
||||
return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401);
|
||||
}
|
||||
|
||||
const [peerId, encryptedSecret] = credentials;
|
||||
|
||||
// Validate credentials (async operation)
|
||||
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Attach peer ID to context for use in handlers
|
||||
c.set('peerId', peerId);
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get authenticated peer ID from context
|
||||
*/
|
||||
export function getAuthenticatedPeerId(c: Context): string {
|
||||
const peerId = c.get('peerId');
|
||||
if (!peerId) {
|
||||
throw new Error('No authenticated peer ID in context');
|
||||
}
|
||||
return peerId;
|
||||
}
|
||||
371
src/storage/d1.ts
Normal file
371
src/storage/d1.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
||||
import { generateOfferHash } from './hash-id.ts';
|
||||
|
||||
/**
|
||||
* D1 storage adapter for topic-based offer management using Cloudflare D1
|
||||
* NOTE: This implementation is a placeholder and needs to be fully tested
|
||||
*/
|
||||
export class D1Storage implements Storage {
|
||||
private db: D1Database;
|
||||
|
||||
/**
|
||||
* Creates a new D1 storage instance
|
||||
* @param db D1Database instance from Cloudflare Workers environment
|
||||
*/
|
||||
constructor(db: D1Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database schema with new topic-based structure
|
||||
* This should be run once during setup, not on every request
|
||||
*/
|
||||
async initializeDatabase(): Promise<void> {
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL,
|
||||
sdp TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
answerer_peer_id TEXT,
|
||||
answer_sdp TEXT,
|
||||
answered_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offer_topics (
|
||||
offer_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
PRIMARY KEY (offer_id, topic),
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ice_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
offer_id TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
||||
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
||||
const created: Offer[] = [];
|
||||
|
||||
// D1 doesn't support true transactions yet, so we do this sequentially
|
||||
for (const offer of offers) {
|
||||
const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
|
||||
const now = Date.now();
|
||||
|
||||
// Insert offer
|
||||
await this.db.prepare(`
|
||||
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now).run();
|
||||
|
||||
// Insert topics
|
||||
for (const topic of offer.topics) {
|
||||
await this.db.prepare(`
|
||||
INSERT INTO offer_topics (offer_id, topic)
|
||||
VALUES (?, ?)
|
||||
`).bind(id, topic).run();
|
||||
}
|
||||
|
||||
created.push({
|
||||
id,
|
||||
peerId: offer.peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
createdAt: now,
|
||||
expiresAt: offer.expiresAt,
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
||||
let query = `
|
||||
SELECT DISTINCT o.*
|
||||
FROM offers o
|
||||
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
||||
WHERE ot.topic = ? AND o.expires_at > ?
|
||||
`;
|
||||
|
||||
const params: any[] = [topic, Date.now()];
|
||||
|
||||
if (excludePeerIds && excludePeerIds.length > 0) {
|
||||
const placeholders = excludePeerIds.map(() => '?').join(',');
|
||||
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
||||
params.push(...excludePeerIds);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.last_seen DESC';
|
||||
|
||||
const result = await this.db.prepare(query).bind(...params).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND expires_at > ?
|
||||
ORDER BY last_seen DESC
|
||||
`).bind(peerId, Date.now()).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async getOfferById(offerId: string): Promise<Offer | null> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`).bind(offerId, Date.now()).first();
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToOffer(result as any);
|
||||
}
|
||||
|
||||
async updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void> {
|
||||
await this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET last_seen = ?
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`).bind(lastSeen, offerId, Date.now()).run();
|
||||
}
|
||||
|
||||
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
||||
const result = await this.db.prepare(`
|
||||
DELETE FROM offers
|
||||
WHERE id = ? AND peer_id = ?
|
||||
`).bind(offerId, ownerPeerId).run();
|
||||
|
||||
return (result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredOffers(now: number): Promise<number> {
|
||||
const result = await this.db.prepare(`
|
||||
DELETE FROM offers WHERE expires_at < ?
|
||||
`).bind(now).run();
|
||||
|
||||
return result.meta.changes || 0;
|
||||
}
|
||||
|
||||
async answerOffer(
|
||||
offerId: string,
|
||||
answererPeerId: string,
|
||||
answerSdp: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if offer exists and is not expired
|
||||
const offer = await this.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer not found or expired'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if offer already has an answerer
|
||||
if (offer.answererPeerId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered'
|
||||
};
|
||||
}
|
||||
|
||||
// Update offer with answer
|
||||
const result = await this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
||||
WHERE id = ? AND answerer_peer_id IS NULL
|
||||
`).bind(answererPeerId, answerSdp, Date.now(), offerId).run();
|
||||
|
||||
if ((result.meta.changes || 0) === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered (race condition)'
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
||||
ORDER BY answered_at DESC
|
||||
`).bind(offererPeerId, Date.now()).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number> {
|
||||
console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
|
||||
|
||||
// Give each candidate a unique timestamp to avoid "since" filtering issues
|
||||
// D1 doesn't have transactions, so insert one by one
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const timestamp = Date.now() + i; // Ensure unique timestamps
|
||||
await this.db.prepare(`
|
||||
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).bind(
|
||||
offerId,
|
||||
peerId,
|
||||
role,
|
||||
JSON.stringify(candidates[i]), // Store full object as JSON
|
||||
timestamp
|
||||
).run();
|
||||
}
|
||||
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
async getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]> {
|
||||
let query = `
|
||||
SELECT * FROM ice_candidates
|
||||
WHERE offer_id = ? AND role = ?
|
||||
`;
|
||||
|
||||
const params: any[] = [offerId, targetRole];
|
||||
|
||||
if (since !== undefined) {
|
||||
query += ' AND created_at > ?';
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at ASC';
|
||||
|
||||
console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
|
||||
const result = await this.db.prepare(query).bind(...params).all();
|
||||
console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = result.results.map((row: any) => ({
|
||||
id: row.id,
|
||||
offerId: row.offer_id,
|
||||
peerId: row.peer_id,
|
||||
role: row.role,
|
||||
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
|
||||
if (candidates.length > 0) {
|
||||
console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}> {
|
||||
// Get total count of topics with active offers
|
||||
const countResult = await this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT ot.topic) as count
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
`).bind(Date.now()).first();
|
||||
|
||||
const total = (countResult as any)?.count || 0;
|
||||
|
||||
// Get topics with peer counts (paginated)
|
||||
const topicsResult = await this.db.prepare(`
|
||||
SELECT
|
||||
ot.topic,
|
||||
COUNT(DISTINCT o.peer_id) as active_peers
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
GROUP BY ot.topic
|
||||
ORDER BY active_peers DESC, ot.topic ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`).bind(Date.now(), limit, offset).all();
|
||||
|
||||
const topics = (topicsResult.results || []).map((row: any) => ({
|
||||
topic: row.topic,
|
||||
activePeers: row.active_peers,
|
||||
}));
|
||||
|
||||
return { topics, total };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// D1 doesn't require explicit connection closing
|
||||
// Connections are managed by the Cloudflare Workers runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert database row to Offer object with topics
|
||||
*/
|
||||
private async rowToOffer(row: any): Promise<Offer> {
|
||||
// Get topics for this offer
|
||||
const topicResult = await this.db.prepare(`
|
||||
SELECT topic FROM offer_topics WHERE offer_id = ?
|
||||
`).bind(row.id).all();
|
||||
|
||||
const topics = topicResult.results?.map((t: any) => t.topic) || [];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
peerId: row.peer_id,
|
||||
sdp: row.sdp,
|
||||
topics,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeen: row.last_seen,
|
||||
answererPeerId: row.answerer_peer_id || undefined,
|
||||
answerSdp: row.answer_sdp || undefined,
|
||||
answeredAt: row.answered_at || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
src/storage/hash-id.ts
Normal file
37
src/storage/hash-id.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Generates a content-based offer ID using SHA-256 hash
|
||||
* Creates deterministic IDs based on offer content (sdp, topics)
|
||||
* PeerID is not included as it's inferred from authentication
|
||||
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
||||
*
|
||||
* @param sdp - The WebRTC SDP offer
|
||||
* @param topics - Array of topic strings
|
||||
* @returns SHA-256 hash of the sanitized offer content
|
||||
*/
|
||||
export async function generateOfferHash(
|
||||
sdp: string,
|
||||
topics: string[]
|
||||
): Promise<string> {
|
||||
// Sanitize and normalize the offer content
|
||||
// Only include core offer content (not peerId - that's inferred from auth)
|
||||
const sanitizedOffer = {
|
||||
sdp,
|
||||
topics: [...topics].sort(), // Sort topics for consistency
|
||||
};
|
||||
|
||||
// Create non-prettified JSON string
|
||||
const jsonString = JSON.stringify(sanitizedOffer);
|
||||
|
||||
// Convert string to Uint8Array for hashing
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(jsonString);
|
||||
|
||||
// Generate SHA-256 hash
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
// Convert hash to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return hashHex;
|
||||
}
|
||||
385
src/storage/sqlite.ts
Normal file
385
src/storage/sqlite.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
||||
import { generateOfferHash } from './hash-id.ts';
|
||||
|
||||
/**
|
||||
* SQLite storage adapter for topic-based offer 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database schema with new topic-based structure
|
||||
*/
|
||||
private initializeDatabase(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL,
|
||||
sdp TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
answerer_peer_id TEXT,
|
||||
answer_sdp TEXT,
|
||||
answered_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offer_topics (
|
||||
offer_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
PRIMARY KEY (offer_id, topic),
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ice_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
offer_id TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
||||
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
||||
`);
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
||||
const created: Offer[] = [];
|
||||
|
||||
// Generate hash-based IDs for all offers first
|
||||
const offersWithIds = await Promise.all(
|
||||
offers.map(async (offer) => ({
|
||||
...offer,
|
||||
id: offer.id || await generateOfferHash(offer.sdp, offer.topics),
|
||||
}))
|
||||
);
|
||||
|
||||
// Use transaction for atomic creation
|
||||
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
|
||||
const offerStmt = this.db.prepare(`
|
||||
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const topicStmt = this.db.prepare(`
|
||||
INSERT INTO offer_topics (offer_id, topic)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
for (const offer of offersWithIds) {
|
||||
const now = Date.now();
|
||||
|
||||
// Insert offer
|
||||
offerStmt.run(
|
||||
offer.id,
|
||||
offer.peerId,
|
||||
offer.sdp,
|
||||
now,
|
||||
offer.expiresAt,
|
||||
now
|
||||
);
|
||||
|
||||
// Insert topics
|
||||
for (const topic of offer.topics) {
|
||||
topicStmt.run(offer.id, topic);
|
||||
}
|
||||
|
||||
created.push({
|
||||
id: offer.id,
|
||||
peerId: offer.peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
createdAt: now,
|
||||
expiresAt: offer.expiresAt,
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
transaction(offersWithIds);
|
||||
return created;
|
||||
}
|
||||
|
||||
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
||||
let query = `
|
||||
SELECT DISTINCT o.*
|
||||
FROM offers o
|
||||
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
||||
WHERE ot.topic = ? AND o.expires_at > ?
|
||||
`;
|
||||
|
||||
const params: any[] = [topic, Date.now()];
|
||||
|
||||
if (excludePeerIds && excludePeerIds.length > 0) {
|
||||
const placeholders = excludePeerIds.map(() => '?').join(',');
|
||||
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
||||
params.push(...excludePeerIds);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.last_seen DESC';
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as any[];
|
||||
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND expires_at > ?
|
||||
ORDER BY last_seen DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(peerId, Date.now()) as any[];
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async getOfferById(offerId: string): Promise<Offer | null> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(offerId, Date.now()) as any;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToOffer(row);
|
||||
}
|
||||
|
||||
async updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void> {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET last_seen = ?
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`);
|
||||
|
||||
stmt.run(lastSeen, offerId, Date.now());
|
||||
}
|
||||
|
||||
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM offers
|
||||
WHERE id = ? AND peer_id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(offerId, ownerPeerId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredOffers(now: number): Promise<number> {
|
||||
const stmt = this.db.prepare('DELETE FROM offers WHERE expires_at < ?');
|
||||
const result = stmt.run(now);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
async answerOffer(
|
||||
offerId: string,
|
||||
answererPeerId: string,
|
||||
answerSdp: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if offer exists and is not expired
|
||||
const offer = await this.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer not found or expired'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if offer already has an answerer
|
||||
if (offer.answererPeerId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered'
|
||||
};
|
||||
}
|
||||
|
||||
// Update offer with answer
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
||||
WHERE id = ? AND answerer_peer_id IS NULL
|
||||
`);
|
||||
|
||||
const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered (race condition)'
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
||||
ORDER BY answered_at DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(offererPeerId, Date.now()) as any[];
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number> {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const baseTimestamp = Date.now();
|
||||
const transaction = this.db.transaction((candidates: any[]) => {
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
stmt.run(
|
||||
offerId,
|
||||
peerId,
|
||||
role,
|
||||
JSON.stringify(candidates[i]), // Store full object as JSON
|
||||
baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
transaction(candidates);
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
async getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]> {
|
||||
let query = `
|
||||
SELECT * FROM ice_candidates
|
||||
WHERE offer_id = ? AND role = ?
|
||||
`;
|
||||
|
||||
const params: any[] = [offerId, targetRole];
|
||||
|
||||
if (since !== undefined) {
|
||||
query += ' AND created_at > ?';
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at ASC';
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
offerId: row.offer_id,
|
||||
peerId: row.peer_id,
|
||||
role: row.role,
|
||||
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}> {
|
||||
// Get total count of topics with active offers
|
||||
const countStmt = this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT ot.topic) as count
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
`);
|
||||
|
||||
const countRow = countStmt.get(Date.now()) as any;
|
||||
const total = countRow.count;
|
||||
|
||||
// Get topics with peer counts (paginated)
|
||||
const topicsStmt = this.db.prepare(`
|
||||
SELECT
|
||||
ot.topic,
|
||||
COUNT(DISTINCT o.peer_id) as active_peers
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
GROUP BY ot.topic
|
||||
ORDER BY active_peers DESC, ot.topic ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const rows = topicsStmt.all(Date.now(), limit, offset) as any[];
|
||||
|
||||
const topics = rows.map(row => ({
|
||||
topic: row.topic,
|
||||
activePeers: row.active_peers,
|
||||
}));
|
||||
|
||||
return { topics, total };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert database row to Offer object with topics
|
||||
*/
|
||||
private async rowToOffer(row: any): Promise<Offer> {
|
||||
// Get topics for this offer
|
||||
const topicStmt = this.db.prepare(`
|
||||
SELECT topic FROM offer_topics WHERE offer_id = ?
|
||||
`);
|
||||
|
||||
const topicRows = topicStmt.all(row.id) as any[];
|
||||
const topics = topicRows.map(t => t.topic);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
peerId: row.peer_id,
|
||||
sdp: row.sdp,
|
||||
topics,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeen: row.last_seen,
|
||||
answererPeerId: row.answerer_peer_id || undefined,
|
||||
answerSdp: row.answer_sdp || undefined,
|
||||
answeredAt: row.answered_at || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
src/storage/types.ts
Normal file
167
src/storage/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Represents a WebRTC signaling offer with topic-based discovery
|
||||
*/
|
||||
export interface Offer {
|
||||
id: string;
|
||||
peerId: string;
|
||||
sdp: string;
|
||||
topics: string[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
lastSeen: number;
|
||||
answererPeerId?: string;
|
||||
answerSdp?: string;
|
||||
answeredAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an ICE candidate for WebRTC signaling
|
||||
* Stores the complete candidate object as plain JSON (no type enforcement)
|
||||
*/
|
||||
export interface IceCandidate {
|
||||
id: number;
|
||||
offerId: string;
|
||||
peerId: string;
|
||||
role: 'offerer' | 'answerer';
|
||||
candidate: any; // Full candidate object as JSON - don't enforce structure
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a topic with active peer count
|
||||
*/
|
||||
export interface TopicInfo {
|
||||
topic: string;
|
||||
activePeers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a new offer
|
||||
*/
|
||||
export interface CreateOfferRequest {
|
||||
id?: string;
|
||||
peerId: string;
|
||||
sdp: string;
|
||||
topics: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage interface for offer management with topic-based discovery
|
||||
* Implementations can use different backends (SQLite, D1, Memory, etc.)
|
||||
*/
|
||||
export interface Storage {
|
||||
/**
|
||||
* Creates one or more offers
|
||||
* @param offers Array of offer creation requests
|
||||
* @returns Array of created offers with IDs
|
||||
*/
|
||||
createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves offers by topic with optional peer ID exclusion
|
||||
* @param topic Topic to search for
|
||||
* @param excludePeerIds Optional array of peer IDs to exclude
|
||||
* @returns Array of offers matching the topic
|
||||
*/
|
||||
getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves all offers from a specific peer
|
||||
* @param peerId Peer identifier
|
||||
* @returns Array of offers from the peer
|
||||
*/
|
||||
getOffersByPeerId(peerId: string): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific offer by ID
|
||||
* @param offerId Offer identifier
|
||||
* @returns The offer if found, null otherwise
|
||||
*/
|
||||
getOfferById(offerId: string): Promise<Offer | null>;
|
||||
|
||||
/**
|
||||
* Updates the last_seen timestamp for an offer (heartbeat)
|
||||
* @param offerId Offer identifier
|
||||
* @param lastSeen New last_seen timestamp
|
||||
*/
|
||||
updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes an offer (with ownership verification)
|
||||
* @param offerId Offer identifier
|
||||
* @param ownerPeerId Peer ID of the owner (for verification)
|
||||
* @returns true if deleted, false if not found or not owned
|
||||
*/
|
||||
deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Deletes all expired offers
|
||||
* @param now Current timestamp
|
||||
* @returns Number of offers deleted
|
||||
*/
|
||||
deleteExpiredOffers(now: number): Promise<number>;
|
||||
|
||||
/**
|
||||
* Answers an offer (locks it to the answerer)
|
||||
* @param offerId Offer identifier
|
||||
* @param answererPeerId Answerer's peer ID
|
||||
* @param answerSdp WebRTC answer SDP
|
||||
* @returns Success status and optional error message
|
||||
*/
|
||||
answerOffer(offerId: string, answererPeerId: string, answerSdp: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Retrieves all answered offers for a specific offerer
|
||||
* @param offererPeerId Offerer's peer ID
|
||||
* @returns Array of answered offers
|
||||
*/
|
||||
getAnsweredOffers(offererPeerId: string): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Adds ICE candidates for an offer
|
||||
* @param offerId Offer identifier
|
||||
* @param peerId Peer ID posting the candidates
|
||||
* @param role Role of the peer (offerer or answerer)
|
||||
* @param candidates Array of candidate objects (stored as plain JSON)
|
||||
* @returns Number of candidates added
|
||||
*/
|
||||
addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieves ICE candidates for an offer
|
||||
* @param offerId Offer identifier
|
||||
* @param targetRole Role to retrieve candidates for (offerer or answerer)
|
||||
* @param since Optional timestamp - only return candidates after this time
|
||||
* @returns Array of ICE candidates
|
||||
*/
|
||||
getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]>;
|
||||
|
||||
/**
|
||||
* Retrieves topics with active peer counts (paginated)
|
||||
* @param limit Maximum number of topics to return
|
||||
* @param offset Number of topics to skip
|
||||
* @returns Object with topics array and total count
|
||||
*/
|
||||
getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Closes the storage connection and releases resources
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
74
src/worker.ts
Normal file
74
src/worker.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createApp } from './app.ts';
|
||||
import { D1Storage } from './storage/d1.ts';
|
||||
import { generateSecretKey } from './crypto.ts';
|
||||
import { Config } from './config.ts';
|
||||
|
||||
/**
|
||||
* Cloudflare Workers environment bindings
|
||||
*/
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
AUTH_SECRET?: string;
|
||||
OFFER_DEFAULT_TTL?: string;
|
||||
OFFER_MAX_TTL?: string;
|
||||
OFFER_MIN_TTL?: string;
|
||||
MAX_OFFERS_PER_REQUEST?: string;
|
||||
MAX_TOPICS_PER_OFFER?: string;
|
||||
CORS_ORIGINS?: string;
|
||||
VERSION?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers fetch handler
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Initialize D1 storage
|
||||
const storage = new D1Storage(env.DB);
|
||||
|
||||
// Generate or use provided auth secret
|
||||
const authSecret = env.AUTH_SECRET || generateSecretKey();
|
||||
|
||||
// Build config from environment
|
||||
const config: Config = {
|
||||
port: 0, // Not used in Workers
|
||||
storageType: 'sqlite', // D1 is SQLite-compatible
|
||||
storagePath: '', // Not used with D1
|
||||
corsOrigins: env.CORS_ORIGINS
|
||||
? env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'],
|
||||
version: env.VERSION || 'unknown',
|
||||
authSecret,
|
||||
offerDefaultTtl: env.OFFER_DEFAULT_TTL ? parseInt(env.OFFER_DEFAULT_TTL, 10) : 60000,
|
||||
offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000,
|
||||
offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000,
|
||||
cleanupInterval: 60000, // Not used in Workers (scheduled handler instead)
|
||||
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100,
|
||||
maxTopicsPerOffer: env.MAX_TOPICS_PER_OFFER ? parseInt(env.MAX_TOPICS_PER_OFFER, 10) : 50,
|
||||
};
|
||||
|
||||
// Create Hono app
|
||||
const app = createApp(storage, config);
|
||||
|
||||
// Handle request
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Scheduled handler for cron triggers
|
||||
* Runs periodically 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 offers
|
||||
const deletedCount = await storage.deleteExpiredOffers(now);
|
||||
|
||||
console.log(`Cleaned up ${deletedCount} expired offers at ${new Date(now).toISOString()}`);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up offers:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user