Compare commits

6 Commits

Author SHA1 Message Date
3efed6e9d2 Fix service reconnection: return available offer from pool
Modified /services/:uuid endpoint to return an available (unanswered)
offer from the service's offer pool instead of always returning the
initial offer. This fixes reconnection failures where clients would
try to answer already-consumed offers.

Changes:
- Query all offers from the service's peer ID
- Return first unanswered offer
- Return 503 if no offers available

Fixes: "Offer already answered" errors on reconnection attempts

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:47:00 +01:00
1257867dff fix: implement upsert behavior for service creation
When a service is republished (e.g., for TTL refresh), the old service
is now deleted before creating a new one, preventing UNIQUE constraint
errors on (username, service_fqn).

Changes:
- Query for existing service before creation
- Delete existing service if found
- Create new service with same username/serviceFqn

This enables the client's TTL auto-refresh feature to work correctly.
2025-12-06 13:04:45 +01:00
52cf734858 Remove legacy V1 code and clean up unused remnants
- Delete unused bloom.ts module (leftover from topic-based discovery)
- Remove maxTopicsPerOffer configuration (no longer used)
- Remove unused info field from Offer types
- Simplify generateOfferHash() to only hash SDP (remove topics param)
- Update outdated comments referencing deprecated features
- Remove backward compatibility topics field from answer responses

This completes the migration to V2 service-based architecture by
removing all remnants of the V1 topic-based system.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 12:06:02 +01:00
5622867411 Add upsert behavior to service creation
- Delete existing service before creating new one
- Prevents UNIQUE constraint error on (username, service_fqn)
- Enables seamless service republishing

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:46:21 +01:00
ac0e064e34 Fix answer response field names for V2 API compatibility
- Change 'answererPeerId' to 'answererId'
- Change 'answerSdp' to 'sdp'
- Add 'topics' field (empty array) for client compatibility

This ensures the server response matches the expected format
in the client's AnsweredOffer interface.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:37:31 +01:00
e7cd90b905 Fix error handling scope issue in service creation
The error handler was referencing variables (username, serviceFqn, offers)
that were declared inside the try block. If an error occurred before these
were defined, the error handler itself would fail, resulting in non-JSON
responses that caused "JSON.parse: unexpected character" errors on the client.

Fixed by:
- Declaring variables at function scope
- Initializing offers as empty array
- Using destructuring assignment for username/serviceFqn

This ensures the error handler can always access these variables safely,
even if an early error occurs, and will always return proper JSON responses.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:56:06 +01:00
9 changed files with 49 additions and 99 deletions

View File

@@ -61,7 +61,7 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* POST /register * POST /register
* Register a new peer (still needed for peer ID generation) * Register a new peer
*/ */
app.post('/register', async (c) => { app.post('/register', async (c) => {
try { try {
@@ -182,9 +182,14 @@ export function createApp(storage: Storage, config: Config) {
* Publish a service * Publish a service
*/ */
app.post('/services', authMiddleware, async (c) => { app.post('/services', authMiddleware, async (c) => {
let username: string | undefined;
let serviceFqn: string | undefined;
let offers: any[] = [];
try { try {
const body = await c.req.json(); const body = await c.req.json();
const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body; ({ username, serviceFqn } = body);
const { sdp, ttl, isPublic, metadata, signature, message } = body;
if (!username || !serviceFqn || !sdp) { if (!username || !serviceFqn || !sdp) {
return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400); return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400);
@@ -212,6 +217,15 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Invalid signature for username' }, 403); return c.json({ error: 'Invalid signature for username' }, 403);
} }
// Delete existing service if one exists (upsert behavior)
const existingUuid = await storage.queryService(username, serviceFqn);
if (existingUuid) {
const existingService = await storage.getServiceByUuid(existingUuid);
if (existingService) {
await storage.deleteService(existingService.id, username);
}
}
// Validate SDP // Validate SDP
if (typeof sdp !== 'string' || sdp.length === 0) { if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'Invalid SDP' }, 400); return c.json({ error: 'Invalid SDP' }, 400);
@@ -230,7 +244,7 @@ export function createApp(storage: Storage, config: Config) {
const expiresAt = Date.now() + offerTtl; const expiresAt = Date.now() + offerTtl;
// Create offer first // Create offer first
const offers = await storage.createOffers([{ offers = await storage.createOffers([{
peerId, peerId,
sdp, sdp,
expiresAt expiresAt
@@ -277,6 +291,7 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* GET /services/:uuid * GET /services/:uuid
* Get service details by index UUID * Get service details by index UUID
* Returns an available (unanswered) offer from the service's pool
*/ */
app.get('/services/:uuid', async (c) => { app.get('/services/:uuid', async (c) => {
try { try {
@@ -288,19 +303,32 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Service not found' }, 404); return c.json({ error: 'Service not found' }, 404);
} }
// Get associated offer // Get the initial offer to find the peer ID
const offer = await storage.getOfferById(service.offerId); const initialOffer = await storage.getOfferById(service.offerId);
if (!offer) { if (!initialOffer) {
return c.json({ error: 'Associated offer not found' }, 404); return c.json({ error: 'Associated offer not found' }, 404);
} }
// Get all offers from this peer
const peerOffers = await storage.getOffersByPeerId(initialOffer.peerId);
// Find an unanswered offer
const availableOffer = peerOffers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'All offers from this service are currently in use. Please try again later.'
}, 503);
}
return c.json({ return c.json({
serviceId: service.id, serviceId: service.id,
username: service.username, username: service.username,
serviceFqn: service.serviceFqn, serviceFqn: service.serviceFqn,
offerId: service.offerId, offerId: availableOffer.id,
sdp: offer.sdp, sdp: availableOffer.sdp,
isPublic: service.isPublic, isPublic: service.isPublic,
metadata: service.metadata ? JSON.parse(service.metadata) : undefined, metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
createdAt: service.createdAt, createdAt: service.createdAt,
@@ -530,8 +558,8 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ return c.json({
answers: offers.map(offer => ({ answers: offers.map(offer => ({
offerId: offer.id, offerId: offer.id,
answererPeerId: offer.answererPeerId, answererId: offer.answererPeerId,
answerSdp: offer.answerSdp, sdp: offer.answerSdp,
answeredAt: offer.answeredAt answeredAt: offer.answeredAt
})) }))
}, 200); }, 200);

View File

@@ -1,66 +0,0 @@
/**
* 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;
}
}

View File

@@ -16,7 +16,6 @@ export interface Config {
offerMinTtl: number; offerMinTtl: number;
cleanupInterval: number; cleanupInterval: number;
maxOffersPerRequest: number; maxOffersPerRequest: number;
maxTopicsPerOffer: number;
} }
/** /**
@@ -45,7 +44,6 @@ export function loadConfig(): Config {
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10), cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10), maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10)
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
}; };
} }

View File

@@ -20,7 +20,6 @@ async function main() {
offerMinTtl: `${config.offerMinTtl}ms`, offerMinTtl: `${config.offerMinTtl}ms`,
cleanupInterval: `${config.cleanupInterval}ms`, cleanupInterval: `${config.cleanupInterval}ms`,
maxOffersPerRequest: config.maxOffersPerRequest, maxOffersPerRequest: config.maxOffersPerRequest,
maxTopicsPerOffer: config.maxTopicsPerOffer,
corsOrigins: config.corsOrigins, corsOrigins: config.corsOrigins,
version: config.version, version: config.version,
}); });

View File

@@ -34,7 +34,7 @@ export class D1Storage implements Storage {
*/ */
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
await this.db.exec(` await this.db.exec(`
-- Offers table (no topics) -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
@@ -125,7 +125,7 @@ export class D1Storage implements Storage {
// D1 doesn't support true transactions yet, so we do this sequentially // D1 doesn't support true transactions yet, so we do this sequentially
for (const offer of offers) { for (const offer of offers) {
const id = offer.id || await generateOfferHash(offer.sdp, []); const id = offer.id || await generateOfferHash(offer.sdp);
const now = Date.now(); const now = Date.now();
await this.db.prepare(` await this.db.prepare(`

View File

@@ -1,22 +1,17 @@
/** /**
* Generates a content-based offer ID using SHA-256 hash * Generates a content-based offer ID using SHA-256 hash
* Creates deterministic IDs based on offer content (sdp, topics) * Creates deterministic IDs based on offer SDP content
* PeerID is not included as it's inferred from authentication * PeerID is not included as it's inferred from authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* *
* @param sdp - The WebRTC SDP offer * @param sdp - The WebRTC SDP offer
* @param topics - Array of topic strings * @returns SHA-256 hash of the SDP content
* @returns SHA-256 hash of the sanitized offer content
*/ */
export async function generateOfferHash( export async function generateOfferHash(sdp: string): Promise<string> {
sdp: string,
topics: string[]
): Promise<string> {
// Sanitize and normalize the offer content // Sanitize and normalize the offer content
// Only include core offer content (not peerId - that's inferred from auth) // Only include core offer content (not peerId - that's inferred from auth)
const sanitizedOffer = { const sanitizedOffer = {
sdp, sdp
topics: [...topics].sort(), // Sort topics for consistency
}; };
// Create non-prettified JSON string // Create non-prettified JSON string

View File

@@ -36,7 +36,7 @@ export class SQLiteStorage implements Storage {
*/ */
private initializeDatabase(): void { private initializeDatabase(): void {
this.db.exec(` this.db.exec(`
-- Offers table (no topics) -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
@@ -132,7 +132,7 @@ export class SQLiteStorage implements Storage {
const offersWithIds = await Promise.all( const offersWithIds = await Promise.all(
offers.map(async (offer) => ({ offers.map(async (offer) => ({
...offer, ...offer,
id: offer.id || await generateOfferHash(offer.sdp, []), id: offer.id || await generateOfferHash(offer.sdp),
})) }))
); );

View File

@@ -1,5 +1,5 @@
/** /**
* Represents a WebRTC signaling offer (no topics) * Represents a WebRTC signaling offer
*/ */
export interface Offer { export interface Offer {
id: string; id: string;
@@ -9,7 +9,6 @@ export interface Offer {
expiresAt: number; expiresAt: number;
lastSeen: number; lastSeen: number;
secret?: string; secret?: string;
info?: string;
answererPeerId?: string; answererPeerId?: string;
answerSdp?: string; answerSdp?: string;
answeredAt?: number; answeredAt?: number;
@@ -37,7 +36,6 @@ export interface CreateOfferRequest {
sdp: string; sdp: string;
expiresAt: number; expiresAt: number;
secret?: string; secret?: string;
info?: string;
} }
/** /**

View File

@@ -13,7 +13,6 @@ export interface Env {
OFFER_MAX_TTL?: string; OFFER_MAX_TTL?: string;
OFFER_MIN_TTL?: string; OFFER_MIN_TTL?: string;
MAX_OFFERS_PER_REQUEST?: string; MAX_OFFERS_PER_REQUEST?: string;
MAX_TOPICS_PER_OFFER?: string;
CORS_ORIGINS?: string; CORS_ORIGINS?: string;
VERSION?: string; VERSION?: string;
} }
@@ -43,8 +42,7 @@ export default {
offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000, offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000,
offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000, offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000,
cleanupInterval: 60000, // Not used in Workers (scheduled handler instead) cleanupInterval: 60000, // Not used in Workers (scheduled handler instead)
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100, 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 // Create Hono app