diff --git a/README.md b/README.md index 0f3f98e..0231c9c 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,469 @@ -# Rondevu - -🎯 **Simple WebRTC peer signaling** - -Connect peers directly by ID with automatic WebRTC negotiation. - -**Related repositories:** -- [rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server -- [rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo - ---- - -## @xtr-dev/rondevu-client +# @xtr-dev/rondevu-client [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) -TypeScript client library for Rondevu peer signaling and WebRTC connection management. Handles automatic signaling, ICE candidate exchange, and connection establishment. +🌐 **Topic-based peer discovery and WebRTC signaling client** -### Install +TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling. + +**Related repositories:** +- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server +- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo + +--- + +## Features + +- **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes) +- **Stateless Authentication**: No server-side sessions, portable credentials +- **Bloom Filters**: Efficient peer exclusion for repeated discoveries +- **Multi-Offer Management**: Create and manage multiple offers per peer +- **Complete WebRTC Signaling**: Full offer/answer and ICE candidate exchange +- **TypeScript**: Full type safety and autocomplete + +## Install ```bash npm install @xtr-dev/rondevu-client ``` -### Usage +## Quick Start -#### Browser +### Browser (Modern with native fetch) + +```typescript +import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client'; + +const client = new Rondevu({ baseUrl: 'https://rondevu.xtrdev.workers.dev' }); + +// 1. Register and get credentials +const creds = await client.register(); +console.log('Peer ID:', creds.peerId); + +// Save credentials for later use +localStorage.setItem('rondevu-creds', JSON.stringify(creds)); + +// 2. Create offer with topics +const offers = await client.offers.create([{ + sdp: 'v=0...', // Your WebRTC offer SDP + topics: ['movie-xyz', 'hd-content'], + ttl: 300000 // 5 minutes +}]); + +// 3. Discover peers by topic +const discovered = await client.offers.findByTopic('movie-xyz', { + limit: 50 +}); + +console.log(`Found ${discovered.length} peers`); + +// 4. Use bloom filter to exclude known peers +const knownPeers = new Set(['peer-id-1', 'peer-id-2']); +const bloom = new BloomFilter(1024, 3); +knownPeers.forEach(id => bloom.add(id)); + +const newPeers = await client.offers.findByTopic('movie-xyz', { + bloomFilter: bloom.toBytes(), + limit: 50 +}); +``` + +### Node.js (< 18 without native fetch) + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; +import fetch from 'node-fetch'; + +const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev', + fetch: fetch as any +}); + +const creds = await client.register(); +console.log('Registered:', creds.peerId); +``` + +### Node.js 18+ (with native fetch) ```typescript import { Rondevu } from '@xtr-dev/rondevu-client'; -const rdv = new Rondevu({ - baseUrl: 'https://api.ronde.vu', - rtcConfig: { - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' } - ] +// No need to provide fetch, it's available globally +const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev' +}); + +const creds = await client.register(); +``` + +### Deno + +```typescript +import { Rondevu } from 'npm:@xtr-dev/rondevu-client'; + +// Deno has native fetch, no polyfill needed +const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev' +}); + +const creds = await client.register(); +``` + +### Bun + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +// Bun has native fetch, no polyfill needed +const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev' +}); + +const creds = await client.register(); +``` + +### Cloudflare Workers + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +export default { + async fetch(request: Request, env: Env) { + const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev' + }); + + const creds = await client.register(); + return new Response(JSON.stringify(creds)); } +}; +``` + +## WebRTC Connection Manager + +For most use cases, you should use the high-level `RondevuConnection` class instead of manually managing WebRTC connections. It handles all the complexity of offer/answer exchange, ICE candidates, and connection lifecycle. + +### Creating an Offer (Peer A) + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +const client = new Rondevu({ baseUrl: 'https://rondevu.xtrdev.workers.dev' }); +await client.register(); + +// Create a connection +const conn = client.createConnection(); + +// Set up event listeners +conn.on('connected', () => { + console.log('Connected to peer!'); }); -// Create an offer with custom ID -const connection = await rdv.offer('my-room-123'); +conn.on('datachannel', (channel) => { + console.log('Data channel ready'); -// Or answer an existing offer -const connection = await rdv.answer('my-room-123'); + channel.onmessage = (event) => { + console.log('Received:', event.data); + }; -// Use data channels -connection.on('connect', () => { - const channel = connection.dataChannel('chat'); - channel.send('Hello!'); + channel.send('Hello from peer A!'); }); -connection.on('datachannel', (channel) => { - if (channel.label === 'chat') { +// Create offer and advertise on topics +const offerId = await conn.createOffer({ + topics: ['my-app', 'room-123'], + ttl: 300000 // 5 minutes +}); + +console.log('Offer created:', offerId); +console.log('Share these topics with peers:', ['my-app', 'room-123']); +``` + +### Answering an Offer (Peer B) + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +const client = new Rondevu({ baseUrl: 'https://rondevu.xtrdev.workers.dev' }); +await client.register(); + +// Discover offers by topic +const offers = await client.offers.findByTopic('my-app', { limit: 10 }); + +if (offers.length > 0) { + const offer = offers[0]; + + // Create connection + const conn = client.createConnection(); + + // Set up event listeners + conn.on('connecting', () => { + console.log('Connecting...'); + }); + + conn.on('connected', () => { + console.log('Connected!'); + }); + + conn.on('datachannel', (channel) => { + console.log('Data channel ready'); + channel.onmessage = (event) => { console.log('Received:', event.data); }; - } + + channel.send('Hello from peer B!'); + }); + + // Answer the offer + await conn.answer(offer.id, offer.sdp); +} +``` + +### Connection Events + +```typescript +conn.on('connecting', () => { + // Connection is being established +}); + +conn.on('connected', () => { + // Connection established successfully +}); + +conn.on('disconnected', () => { + // Connection lost or closed +}); + +conn.on('error', (error) => { + // An error occurred + console.error('Connection error:', error); +}); + +conn.on('datachannel', (channel) => { + // Data channel is ready to use +}); + +conn.on('track', (event) => { + // Media track received (for audio/video streaming) + const stream = event.streams[0]; + videoElement.srcObject = stream; }); ``` -#### Node.js +### Adding Media Tracks + +```typescript +// Get user's camera/microphone +const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true +}); + +// Add tracks to connection +stream.getTracks().forEach(track => { + conn.addTrack(track, stream); +}); +``` + +### Connection Properties + +```typescript +// Get connection state +console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc. + +// Get offer ID +console.log(conn.id); + +// Get data channel +console.log(conn.channel); +``` + +### Closing a Connection + +```typescript +conn.close(); +``` + +## API Reference + +### Authentication + +#### `client.register()` +Register a new peer and receive credentials. + +```typescript +const creds = await client.register(); +// { peerId: '...', secret: '...' } +``` + +### Topics + +#### `client.offers.getTopics(options?)` +List all topics with active peer counts (paginated). + +```typescript +const result = await client.offers.getTopics({ + limit: 50, + offset: 0 +}); + +// { +// topics: [ +// { topic: 'movie-xyz', activePeers: 42 }, +// { topic: 'torrent-abc', activePeers: 15 } +// ], +// total: 123, +// limit: 50, +// offset: 0 +// } +``` + +### Offers + +#### `client.offers.create(offers)` +Create one or more offers with topics. + +```typescript +const offers = await client.offers.create([ + { + sdp: 'v=0...', + topics: ['topic-1', 'topic-2'], + ttl: 300000 // optional, default 5 minutes + } +]); +``` + +#### `client.offers.findByTopic(topic, options?)` +Find offers by topic with optional bloom filter. + +```typescript +const offers = await client.offers.findByTopic('movie-xyz', { + limit: 50, + bloomFilter: bloomBytes // optional +}); +``` + +#### `client.offers.getMine()` +Get all offers owned by the authenticated peer. + +```typescript +const myOffers = await client.offers.getMine(); +``` + +#### `client.offers.heartbeat(offerId)` +Update last_seen timestamp for an offer. + +```typescript +await client.offers.heartbeat(offerId); +``` + +#### `client.offers.delete(offerId)` +Delete a specific offer. + +```typescript +await client.offers.delete(offerId); +``` + +#### `client.offers.answer(offerId, sdp)` +Answer an offer (locks it to answerer). + +```typescript +await client.offers.answer(offerId, answerSdp); +``` + +#### `client.offers.getAnswers()` +Poll for answers to your offers. + +```typescript +const answers = await client.offers.getAnswers(); +``` + +### ICE Candidates + +#### `client.offers.addIceCandidates(offerId, candidates)` +Post ICE candidates for an offer. + +```typescript +await client.offers.addIceCandidates(offerId, [ + 'candidate:1 1 UDP...' +]); +``` + +#### `client.offers.getIceCandidates(offerId, since?)` +Get ICE candidates from the other peer. + +```typescript +const candidates = await client.offers.getIceCandidates(offerId); +``` + +### Bloom Filter + +```typescript +import { BloomFilter } from '@xtr-dev/rondevu-client'; + +// Create filter: size=1024 bits, hash=3 functions +const bloom = new BloomFilter(1024, 3); + +// Add items +bloom.add('peer-id-1'); +bloom.add('peer-id-2'); + +// Test membership +bloom.test('peer-id-1'); // true (probably) +bloom.test('unknown'); // false (definitely) + +// Export for API +const bytes = bloom.toBytes(); +``` + +## TypeScript + +All types are exported: + +```typescript +import type { + Credentials, + Offer, + CreateOfferRequest, + TopicInfo, + IceCandidate, + FetchFunction, + RondevuOptions, + ConnectionOptions, + RondevuConnectionEvents +} from '@xtr-dev/rondevu-client'; +``` + +## Environment Compatibility + +The client library is designed to work across different JavaScript runtimes: + +| Environment | Native Fetch | Custom Fetch Needed | +|-------------|--------------|---------------------| +| Modern Browsers | ✅ Yes | ❌ No | +| Node.js 18+ | ✅ Yes | ❌ No | +| Node.js < 18 | ❌ No | ✅ Yes (node-fetch) | +| Deno | ✅ Yes | ❌ No | +| Bun | ✅ Yes | ❌ No | +| Cloudflare Workers | ✅ Yes | ❌ No | + +**If your environment doesn't have native fetch:** + +```bash +npm install node-fetch +``` ```typescript import { Rondevu } from '@xtr-dev/rondevu-client'; -import wrtc from '@roamhq/wrtc'; import fetch from 'node-fetch'; -const rdv = new Rondevu({ - baseUrl: 'https://api.ronde.vu', - fetch: fetch as any, - wrtc: { - RTCPeerConnection: wrtc.RTCPeerConnection, - RTCSessionDescription: wrtc.RTCSessionDescription, - RTCIceCandidate: wrtc.RTCIceCandidate, - } -}); - -const connection = await rdv.offer('my-room-123'); - -connection.on('connect', () => { - const channel = connection.dataChannel('chat'); - channel.send('Hello from Node.js!'); +const client = new Rondevu({ + baseUrl: 'https://rondevu.xtrdev.workers.dev', + fetch: fetch as any }); ``` -### API - -**Main Methods:** -- `rdv.offer(id)` - Create an offer with custom ID -- `rdv.answer(id)` - Answer an existing offer by ID - -**Connection Events:** -- `connect` - Connection established -- `disconnect` - Connection closed -- `error` - Connection error -- `datachannel` - New data channel received -- `stream` - Media stream received - -**Connection Methods:** -- `connection.dataChannel(label)` - Get or create data channel -- `connection.addStream(stream)` - Add media stream -- `connection.close()` - Close connection - -### Version Compatibility - -The client automatically checks server compatibility via the `/health` endpoint. If the server version is incompatible, an error will be thrown during initialization. - -### License +## License MIT diff --git a/package.json b/package.json index 90a0871..7c666d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.3.5", - "description": "TypeScript client for Rondevu peer signaling and discovery server", + "version": "0.4.0", + "description": "TypeScript client for Rondevu topic-based peer discovery and signaling server", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..9ae7b60 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,60 @@ +export interface Credentials { + peerId: string; + secret: string; +} + +// Fetch-compatible function type +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit +) => Promise; + +export class RondevuAuth { + private fetchFn: FetchFunction; + + constructor( + private baseUrl: string, + fetchFn?: FetchFunction + ) { + // Use provided fetch or fall back to global fetch + this.fetchFn = fetchFn || ((...args) => { + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch(...args); + } + throw new Error( + 'fetch is not available. Please provide a fetch implementation in the constructor options.' + ); + }); + } + + /** + * Register a new peer and receive credentials + */ + async register(): Promise { + const response = await this.fetchFn(`${this.baseUrl}/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Registration failed: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return { + peerId: data.peerId, + secret: data.secret, + }; + } + + /** + * Create Authorization header value + */ + static createAuthHeader(credentials: Credentials): string { + return `Bearer ${credentials.peerId}:${credentials.secret}`; + } +} diff --git a/src/bloom.ts b/src/bloom.ts new file mode 100644 index 0000000..8800255 --- /dev/null +++ b/src/bloom.ts @@ -0,0 +1,83 @@ +// Declare Buffer for Node.js compatibility +declare const Buffer: any; + +/** + * Simple bloom filter implementation for peer ID exclusion + * Uses multiple hash functions for better distribution + */ +export class BloomFilter { + private bits: Uint8Array; + private size: number; + private numHashes: number; + + constructor(size: number = 1024, numHashes: number = 3) { + this.size = size; + this.numHashes = numHashes; + this.bits = new Uint8Array(Math.ceil(size / 8)); + } + + /** + * Add a peer ID to the filter + */ + add(peerId: string): void { + 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; + this.bits[byteIndex] |= 1 << bitIndex; + } + } + + /** + * Test if peer ID might be in the filter + */ + 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; + } + + /** + * Get raw bits for transmission + */ + toBytes(): Uint8Array { + return this.bits; + } + + /** + * Convert to base64 for URL parameters + */ + toBase64(): string { + // Convert Uint8Array to regular array then to string + const binaryString = String.fromCharCode(...Array.from(this.bits)); + // Use btoa for browser, or Buffer for Node.js + if (typeof btoa !== 'undefined') { + return btoa(binaryString); + } else if (typeof Buffer !== 'undefined') { + return Buffer.from(this.bits).toString('base64'); + } else { + // Fallback: manual base64 encoding + throw new Error('No base64 encoding available'); + } + } + + /** + * 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; + } +} diff --git a/src/connection.ts b/src/connection.ts index 32f431c..6301f2a 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,332 +1,346 @@ -import { EventEmitter } from './event-emitter.js'; -import { RondevuAPI } from './client.js'; -import { RondevuConnectionParams, WebRTCPolyfill } from './types.js'; +import { RondevuOffers } from './offers.js'; /** - * Represents a WebRTC connection with automatic signaling and ICE exchange + * Events emitted by RondevuConnection */ -export class RondevuConnection extends EventEmitter { - readonly id: string; - readonly role: 'offerer' | 'answerer'; - readonly remotePeerId: string; +export interface RondevuConnectionEvents { + 'connecting': () => void; + 'connected': () => void; + 'disconnected': () => void; + 'error': (error: Error) => void; + 'datachannel': (channel: RTCDataChannel) => void; + 'track': (event: RTCTrackEvent) => void; +} +/** + * Options for creating a WebRTC connection + */ +export interface ConnectionOptions { + /** + * RTCConfiguration for the peer connection + * @default { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] } + */ + rtcConfig?: RTCConfiguration; + + /** + * Topics to advertise this connection under + */ + topics: string[]; + + /** + * How long the offer should live (milliseconds) + * @default 300000 (5 minutes) + */ + ttl?: number; + + /** + * Whether to create a data channel automatically (for offerer) + * @default true + */ + createDataChannel?: boolean; + + /** + * Label for the automatically created data channel + * @default 'data' + */ + dataChannelLabel?: string; +} + +/** + * High-level WebRTC connection manager for Rondevu + * Handles offer/answer exchange, ICE candidates, and connection lifecycle + */ +export class RondevuConnection { private pc: RTCPeerConnection; - private client: RondevuAPI; - private localPeerId: string; - private dataChannels: Map; - private pollingInterval?: ReturnType; - private pollingIntervalMs: number; - private connectionTimeoutMs: number; - private connectionTimer?: ReturnType; - private isPolling: boolean = false; - private isClosed: boolean = false; - private hasConnected: boolean = false; - private wrtc?: WebRTCPolyfill; - private RTCIceCandidate: typeof RTCIceCandidate; + private offersApi: RondevuOffers; + private offerId?: string; + private role?: 'offerer' | 'answerer'; + private icePollingInterval?: ReturnType; + private answerPollingInterval?: ReturnType; + private lastIceTimestamp: number = Date.now(); + private eventListeners: Map> = new Map(); + private dataChannel?: RTCDataChannel; - constructor(params: RondevuConnectionParams, client: RondevuAPI) { - super(); - this.id = params.id; - this.role = params.role; - this.pc = params.pc; - this.localPeerId = params.localPeerId; - this.remotePeerId = params.remotePeerId; - this.client = client; - this.dataChannels = new Map(); - this.pollingIntervalMs = params.pollingInterval; - this.connectionTimeoutMs = params.connectionTimeout; - this.wrtc = params.wrtc; - - // Use injected WebRTC polyfill or fall back to global - this.RTCIceCandidate = params.wrtc?.RTCIceCandidate || globalThis.RTCIceCandidate; - - this.setupEventHandlers(); - this.startConnectionTimeout(); + /** + * Current connection state + */ + get connectionState(): RTCPeerConnectionState { + return this.pc.connectionState; } /** - * Setup RTCPeerConnection event handlers + * The offer ID for this connection */ - private setupEventHandlers(): void { - // ICE candidate gathering - this.pc.onicecandidate = (event) => { - if (event.candidate && !this.isClosed) { - this.sendIceCandidate(event.candidate).catch((err) => { - this.emit('error', new Error(`Failed to send ICE candidate: ${err.message}`)); - }); + get id(): string | undefined { + return this.offerId; + } + + /** + * Get the primary data channel (if created) + */ + get channel(): RTCDataChannel | undefined { + return this.dataChannel; + } + + constructor( + offersApi: RondevuOffers, + private rtcConfig: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + } + ) { + this.offersApi = offersApi; + this.pc = new RTCPeerConnection(rtcConfig); + this.setupPeerConnection(); + } + + /** + * Set up peer connection event handlers + */ + private setupPeerConnection(): void { + this.pc.onicecandidate = async (event) => { + if (event.candidate && this.offerId) { + try { + await this.offersApi.addIceCandidates(this.offerId, [event.candidate.candidate]); + } catch (err) { + console.error('Error sending ICE candidate:', err); + } } }; - // Connection state changes this.pc.onconnectionstatechange = () => { - this.handleConnectionStateChange(); - }; - - // Remote data channels - this.pc.ondatachannel = (event) => { - this.handleRemoteDataChannel(event.channel); - }; - - // Remote media streams - this.pc.ontrack = (event) => { - if (event.streams && event.streams[0]) { - this.emit('stream', event.streams[0]); - } - }; - - // ICE connection state changes - this.pc.oniceconnectionstatechange = () => { - const state = this.pc.iceConnectionState; - - if (state === 'failed' || state === 'closed') { - this.emit('error', new Error(`ICE connection ${state}`)); - if (state === 'failed') { - this.close(); - } - } - }; - } - - /** - * Handle RTCPeerConnection state changes - */ - private handleConnectionStateChange(): void { - const state = this.pc.connectionState; - - switch (state) { - case 'connected': - if (!this.hasConnected) { - this.hasConnected = true; - this.clearConnectionTimeout(); + switch (this.pc.connectionState) { + case 'connecting': + this.emit('connecting'); + break; + case 'connected': + this.emit('connected'); + break; + case 'disconnected': + case 'failed': + case 'closed': + this.emit('disconnected'); this.stopPolling(); - this.emit('connect'); - } - break; + break; + } + }; - case 'disconnected': - this.emit('disconnect'); - break; + this.pc.ondatachannel = (event) => { + this.dataChannel = event.channel; + this.emit('datachannel', event.channel); + }; - case 'failed': - this.emit('error', new Error('Connection failed')); - this.close(); - break; + this.pc.ontrack = (event) => { + this.emit('track', event); + }; - case 'closed': - this.emit('disconnect'); - break; - } + this.pc.onicecandidateerror = (event) => { + console.error('ICE candidate error:', event); + }; } /** - * Send an ICE candidate to the remote peer via signaling server + * Create an offer and advertise on topics */ - private async sendIceCandidate(candidate: RTCIceCandidate): Promise { - try { - await this.client.sendAnswer({ - code: this.id, - candidate: JSON.stringify(candidate.toJSON()), - side: this.role, - }); - } catch (err: any) { - throw new Error(`Failed to send ICE candidate: ${err.message}`); + async createOffer(options: ConnectionOptions): Promise { + this.role = 'offerer'; + + // Create data channel if requested + if (options.createDataChannel !== false) { + this.dataChannel = this.pc.createDataChannel( + options.dataChannelLabel || 'data' + ); + this.emit('datachannel', this.dataChannel); } + + // Create WebRTC offer + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + + // Create offer on Rondevu server + const offers = await this.offersApi.create([{ + sdp: offer.sdp!, + topics: options.topics, + ttl: options.ttl || 300000 + }]); + + this.offerId = offers[0].id; + + // Start polling for answers + this.startAnswerPolling(); + + return this.offerId; } /** - * Start polling for remote session data (answer/candidates) + * Answer an existing offer */ - startPolling(): void { - if (this.isPolling || this.isClosed) { - return; - } + async answer(offerId: string, offerSdp: string): Promise { + this.role = 'answerer'; - this.isPolling = true; - - // Poll immediately - this.poll().catch((err) => { - this.emit('error', new Error(`Poll error: ${err.message}`)); + // Set remote description + await this.pc.setRemoteDescription({ + type: 'offer', + sdp: offerSdp }); - // Set up interval polling - this.pollingInterval = setInterval(() => { - this.poll().catch((err) => { - this.emit('error', new Error(`Poll error: ${err.message}`)); - }); - }, this.pollingIntervalMs); + // Create answer + const answer = await this.pc.createAnswer(); + await this.pc.setLocalDescription(answer); + + // Send answer to server FIRST + // This registers us as the answerer before ICE candidates arrive + await this.offersApi.answer(offerId, answer.sdp!); + + // Now set offerId to enable ICE candidate sending + // This prevents a race condition where ICE candidates arrive before answer is registered + this.offerId = offerId; + + // Start polling for ICE candidates + this.startIcePolling(); } /** - * Stop polling + * Start polling for answers (offerer only) */ - private stopPolling(): void { - this.isPolling = false; - if (this.pollingInterval) { - clearInterval(this.pollingInterval); - this.pollingInterval = undefined; - } - } + private startAnswerPolling(): void { + if (this.role !== 'offerer' || !this.offerId) return; - /** - * Poll the signaling server for remote data - */ - private async poll(): Promise { - if (this.isClosed) { - this.stopPolling(); - return; - } + this.answerPollingInterval = setInterval(async () => { + try { + const answers = await this.offersApi.getAnswers(); + const myAnswer = answers.find(a => a.offerId === this.offerId); - try { - const response = await this.client.poll(this.id, this.role); - - if (this.role === 'offerer') { - const offererResponse = response as { answer: string | null; answerCandidates: string[] }; - - // Apply answer if received and not yet applied - if (offererResponse.answer && !this.pc.currentRemoteDescription) { + if (myAnswer) { + // Set remote description await this.pc.setRemoteDescription({ type: 'answer', - sdp: offererResponse.answer, + sdp: myAnswer.sdp }); + + // Stop answer polling, start ICE polling + this.stopAnswerPolling(); + this.startIcePolling(); } + } catch (err) { + console.error('Error polling for answers:', err); + } + }, 2000); + } - // Apply ICE candidates - if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) { - for (const candidateStr of offererResponse.answerCandidates) { - try { - const candidate = JSON.parse(candidateStr); - await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate)); - } catch (err) { - console.warn('Failed to add ICE candidate:', err); - } - } - } - } else { - // Answerer role - const answererResponse = response as { offer: string; offerCandidates: string[] }; - - // Apply ICE candidates from offerer - if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) { - for (const candidateStr of answererResponse.offerCandidates) { - try { - const candidate = JSON.parse(candidateStr); - await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate)); - } catch (err) { - console.warn('Failed to add ICE candidate:', err); - } - } + /** + * Start polling for ICE candidates + */ + private startIcePolling(): void { + if (!this.offerId) return; + + this.icePollingInterval = setInterval(async () => { + if (!this.offerId) return; + + try { + const candidates = await this.offersApi.getIceCandidates( + this.offerId, + this.lastIceTimestamp + ); + + for (const candidate of candidates) { + await this.pc.addIceCandidate({ + candidate: candidate.candidate, + sdpMLineIndex: 0, + sdpMid: '0' + }); + this.lastIceTimestamp = candidate.createdAt; } + } catch (err) { + console.error('Error polling for ICE candidates:', err); } - } catch (err: any) { - // Session not found or expired - if (err.message.includes('404') || err.message.includes('not found')) { - this.emit('error', new Error('Session not found or expired')); - this.close(); - } - throw err; + }, 1000); + } + + /** + * Stop answer polling + */ + private stopAnswerPolling(): void { + if (this.answerPollingInterval) { + clearInterval(this.answerPollingInterval); + this.answerPollingInterval = undefined; } } /** - * Handle remotely created data channel + * Stop ICE polling */ - private handleRemoteDataChannel(channel: RTCDataChannel): void { - this.dataChannels.set(channel.label, channel); - this.emit('datachannel', channel); - } - - /** - * Get or create a data channel - */ - dataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel { - let channel = this.dataChannels.get(label); - - if (!channel) { - channel = this.pc.createDataChannel(label, options); - this.dataChannels.set(label, channel); - } - - return channel; - } - - /** - * Add a local media stream to the connection - */ - addStream(stream: MediaStream): void { - stream.getTracks().forEach(track => { - this.pc.addTrack(track, stream); - }); - } - - /** - * Get the underlying RTCPeerConnection for advanced usage - */ - getPeerConnection(): RTCPeerConnection { - return this.pc; - } - - /** - * Start connection timeout - */ - private startConnectionTimeout(): void { - this.connectionTimer = setTimeout(() => { - if (this.pc.connectionState !== 'connected') { - this.emit('error', new Error('Connection timeout')); - this.close(); - } - }, this.connectionTimeoutMs); - } - - /** - * Clear connection timeout - */ - private clearConnectionTimeout(): void { - if (this.connectionTimer) { - clearTimeout(this.connectionTimer); - this.connectionTimer = undefined; + private stopIcePolling(): void { + if (this.icePollingInterval) { + clearInterval(this.icePollingInterval); + this.icePollingInterval = undefined; } } /** - * Leave the session by deleting the offer on the server and closing the connection - * This ends the session for all connected peers + * Stop all polling */ - async leave(): Promise { - try { - await this.client.leave(this.id); - } catch (err) { - // Ignore errors - session might already be expired - console.debug('Leave error (ignored):', err); - } - this.close(); + private stopPolling(): void { + this.stopAnswerPolling(); + this.stopIcePolling(); } /** - * Close the connection and cleanup resources + * Add event listener + */ + on( + event: K, + listener: RondevuConnectionEvents[K] + ): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(listener); + } + + /** + * Remove event listener + */ + off( + event: K, + listener: RondevuConnectionEvents[K] + ): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.delete(listener); + } + } + + /** + * Emit event + */ + private emit( + event: K, + ...args: Parameters + ): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.forEach(listener => { + (listener as any)(...args); + }); + } + } + + /** + * Add a media track to the connection + */ + addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender { + return this.pc.addTrack(track, ...streams); + } + + /** + * Close the connection and clean up */ close(): void { - if (this.isClosed) { - return; - } - - this.isClosed = true; - this.stopPolling(); - this.clearConnectionTimeout(); - - // Close all data channels - this.dataChannels.forEach(dc => { - if (dc.readyState === 'open' || dc.readyState === 'connecting') { - dc.close(); - } - }); - this.dataChannels.clear(); - - // Close peer connection - if (this.pc.connectionState !== 'closed') { - this.pc.close(); - } - - this.emit('disconnect'); + this.pc.close(); + this.eventListeners.clear(); } } diff --git a/src/index.ts b/src/index.ts index 41ee6d0..54ca941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,31 @@ /** * @xtr-dev/rondevu-client - * WebRTC peer signaling and discovery client + * WebRTC peer signaling and discovery client with topic-based discovery */ -// Export main WebRTC client class +// Export main client class export { Rondevu } from './rondevu.js'; +export type { RondevuOptions } from './rondevu.js'; -// Export connection class -export { RondevuConnection } from './connection.js'; +// Export authentication +export { RondevuAuth } from './auth.js'; +export type { Credentials, FetchFunction } from './auth.js'; -// Export low-level signaling API (for advanced usage) -export { RondevuAPI } from './client.js'; - -// Export all types +// Export offers API +export { RondevuOffers } from './offers.js'; export type { - // WebRTC types - RondevuOptions, - ConnectionRole, - RondevuConnectionParams, - RondevuConnectionEvents, - WebRTCPolyfill, - // Signaling types - Side, CreateOfferRequest, - CreateOfferResponse, - AnswerRequest, - AnswerResponse, - PollRequest, - PollOffererResponse, - PollAnswererResponse, - PollResponse, - VersionResponse, - HealthResponse, - ErrorResponse, - RondevuClientOptions, -} from './types.js'; + Offer, + IceCandidate, + TopicInfo +} from './offers.js'; + +// Export bloom filter +export { BloomFilter } from './bloom.js'; + +// Export connection manager +export { RondevuConnection } from './connection.js'; +export type { + ConnectionOptions, + RondevuConnectionEvents +} from './connection.js'; diff --git a/src/offers.ts b/src/offers.ts new file mode 100644 index 0000000..0822e34 --- /dev/null +++ b/src/offers.ts @@ -0,0 +1,325 @@ +import { Credentials, FetchFunction } from './auth.js'; +import { RondevuAuth } from './auth.js'; + +// Declare Buffer for Node.js compatibility +declare const Buffer: any; + +export interface CreateOfferRequest { + id?: string; + sdp: string; + topics: string[]; + ttl?: number; +} + +export interface Offer { + id: string; + peerId: string; + sdp: string; + topics: string[]; + createdAt?: number; + expiresAt: number; + lastSeen: number; + answererPeerId?: string; + answerSdp?: string; + answeredAt?: number; +} + +export interface IceCandidate { + candidate: string; + peerId: string; + role: 'offerer' | 'answerer'; + createdAt: number; +} + +export interface TopicInfo { + topic: string; + activePeers: number; +} + +export class RondevuOffers { + private fetchFn: FetchFunction; + + constructor( + private baseUrl: string, + private credentials: Credentials, + fetchFn?: FetchFunction + ) { + // Use provided fetch or fall back to global fetch + this.fetchFn = fetchFn || ((...args) => { + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch(...args); + } + throw new Error( + 'fetch is not available. Please provide a fetch implementation in the constructor options.' + ); + }); + } + + /** + * Create one or more offers + */ + async create(offers: CreateOfferRequest[]): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + body: JSON.stringify({ offers }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to create offers: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return data.offers; + } + + /** + * Find offers by topic with optional bloom filter + */ + async findByTopic( + topic: string, + options?: { + bloomFilter?: Uint8Array; + limit?: number; + } + ): Promise { + const params = new URLSearchParams(); + + if (options?.bloomFilter) { + // Convert to base64 + const binaryString = String.fromCharCode(...Array.from(options.bloomFilter)); + const base64 = typeof btoa !== 'undefined' + ? btoa(binaryString) + : (typeof Buffer !== 'undefined' ? Buffer.from(options.bloomFilter).toString('base64') : ''); + params.set('bloom', base64); + } + + if (options?.limit) { + params.set('limit', options.limit.toString()); + } + + const url = `${this.baseUrl}/offers/by-topic/${encodeURIComponent(topic)}${ + params.toString() ? '?' + params.toString() : '' + }`; + + const response = await this.fetchFn(url, { + method: 'GET', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to find offers: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return data.offers; + } + + /** + * Get all offers from a specific peer + */ + async getByPeerId(peerId: string): Promise<{ + offers: Offer[]; + topics: string[]; + }> { + const response = await this.fetchFn(`${this.baseUrl}/peers/${encodeURIComponent(peerId)}/offers`, { + method: 'GET', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get peer offers: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Get topics with active peer counts (paginated) + */ + async getTopics(options?: { + limit?: number; + offset?: number; + }): Promise<{ + topics: TopicInfo[]; + total: number; + limit: number; + offset: number; + }> { + const params = new URLSearchParams(); + + if (options?.limit) { + params.set('limit', options.limit.toString()); + } + + if (options?.offset) { + params.set('offset', options.offset.toString()); + } + + const url = `${this.baseUrl}/topics${ + params.toString() ? '?' + params.toString() : '' + }`; + + const response = await this.fetchFn(url, { + method: 'GET', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get topics: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Get own offers + */ + async getMine(): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers/mine`, { + method: 'GET', + headers: { + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get own offers: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return data.offers; + } + + /** + * Update offer heartbeat + */ + async heartbeat(offerId: string): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/heartbeat`, { + method: 'PUT', + headers: { + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to update heartbeat: ${error.error || response.statusText}`); + } + } + + /** + * Delete an offer + */ + async delete(offerId: string): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}`, { + method: 'DELETE', + headers: { + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to delete offer: ${error.error || response.statusText}`); + } + } + + /** + * Answer an offer + */ + async answer(offerId: string, sdp: string): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/answer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + body: JSON.stringify({ sdp }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to answer offer: ${error.error || response.statusText}`); + } + } + + /** + * Get answers to your offers + */ + async getAnswers(): Promise> { + const response = await this.fetchFn(`${this.baseUrl}/offers/answers`, { + method: 'GET', + headers: { + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get answers: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return data.answers; + } + + /** + * Post ICE candidates for an offer + */ + async addIceCandidates(offerId: string, candidates: string[]): Promise { + const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + body: JSON.stringify({ candidates }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`); + } + } + + /** + * Get ICE candidates for an offer + */ + async getIceCandidates(offerId: string, since?: number): Promise { + const params = new URLSearchParams(); + if (since !== undefined) { + params.set('since', since.toString()); + } + + const url = `${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates${ + params.toString() ? '?' + params.toString() : '' + }`; + + const response = await this.fetchFn(url, { + method: 'GET', + headers: { + Authorization: RondevuAuth.createAuthHeader(this.credentials), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`); + } + + const data = await response.json(); + return data.candidates; + } +} diff --git a/src/rondevu.ts b/src/rondevu.ts index 63bc43c..4e406ad 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -1,255 +1,103 @@ -import { RondevuAPI } from './client.js'; -import { RondevuConnection } from './connection.js'; -import { RondevuOptions, RondevuConnectionParams, WebRTCPolyfill } from './types.js'; +import { RondevuAuth, Credentials, FetchFunction } from './auth.js'; +import { RondevuOffers } from './offers.js'; +import { RondevuConnection, ConnectionOptions } from './connection.js'; -/** - * Main Rondevu WebRTC client with automatic connection management - */ -export class Rondevu { - readonly peerId: string; - readonly api: RondevuAPI; - - private baseUrl: string; - private fetchImpl?: typeof fetch; - private rtcConfig?: RTCConfiguration; - private pollingInterval: number; - private connectionTimeout: number; - private wrtc?: WebRTCPolyfill; - private RTCPeerConnection: typeof RTCPeerConnection; - private RTCIceCandidate: typeof RTCIceCandidate; +export interface RondevuOptions { + /** + * Base URL of the Rondevu server + * @default 'https://api.ronde.vu' + */ + baseUrl?: string; /** - * Creates a new Rondevu client instance - * @param options - Client configuration options + * Existing credentials (peerId + secret) to skip registration */ + credentials?: Credentials; + + /** + * Custom fetch implementation for environments without native fetch + * (Node.js < 18, some Workers environments, etc.) + * + * @example Node.js + * ```typescript + * import fetch from 'node-fetch'; + * const client = new Rondevu({ fetch }); + * ``` + */ + fetch?: FetchFunction; +} + +export class Rondevu { + readonly auth: RondevuAuth; + private _offers?: RondevuOffers; + private credentials?: Credentials; + private baseUrl: string; + private fetchFn?: FetchFunction; + constructor(options: RondevuOptions = {}) { this.baseUrl = options.baseUrl || 'https://api.ronde.vu'; - this.fetchImpl = options.fetch; - this.wrtc = options.wrtc; + this.fetchFn = options.fetch; - this.api = new RondevuAPI({ - baseUrl: this.baseUrl, - fetch: options.fetch, - }); + this.auth = new RondevuAuth(this.baseUrl, this.fetchFn); - // Auto-generate peer ID if not provided - this.peerId = options.peerId || this.generatePeerId(); - this.rtcConfig = options.rtcConfig; - this.pollingInterval = options.pollingInterval || 1000; - this.connectionTimeout = options.connectionTimeout || 30000; - - // Use injected WebRTC polyfill or fall back to global - this.RTCPeerConnection = options.wrtc?.RTCPeerConnection || globalThis.RTCPeerConnection; - this.RTCIceCandidate = options.wrtc?.RTCIceCandidate || globalThis.RTCIceCandidate; - - if (!this.RTCPeerConnection) { - throw new Error( - 'RTCPeerConnection not available. ' + - 'In Node.js, provide a WebRTC polyfill via the wrtc option. ' + - 'Install: npm install @roamhq/wrtc or npm install wrtc' - ); - } - - // Check server version compatibility (async, don't block constructor) - this.checkServerVersion().catch(() => { - // Silently fail version check - connection will work even if version check fails - }); - } - - /** - * Check server version compatibility - */ - private async checkServerVersion(): Promise { - try { - const { version: serverVersion } = await this.api.health(); - const clientVersion = '0.3.5'; // Should match package.json - - if (!this.isVersionCompatible(clientVersion, serverVersion)) { - console.warn( - `[Rondevu] Version mismatch: client v${clientVersion}, server v${serverVersion}. ` + - 'This may cause compatibility issues.' - ); - } - } catch (error) { - // Version check failed - server might not support /health endpoint - console.debug('[Rondevu] Could not check server version'); + if (options.credentials) { + this.credentials = options.credentials; + this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn); } } /** - * Check if client and server versions are compatible - * For now, just check major version compatibility + * Get offers API (requires authentication) */ - private isVersionCompatible(clientVersion: string, serverVersion: string): boolean { - const clientMajor = parseInt(clientVersion.split('.')[0]); - const serverMajor = parseInt(serverVersion.split('.')[0]); - - // Major versions must match - return clientMajor === serverMajor; + get offers(): RondevuOffers { + if (!this._offers) { + throw new Error('Not authenticated. Call register() first or provide credentials.'); + } + return this._offers; } /** - * Generate a unique peer ID + * Register and initialize authenticated client */ - private generatePeerId(): string { - return `rdv_${Math.random().toString(36).substring(2, 14)}`; + async register(): Promise { + this.credentials = await this.auth.register(); + + // Create offers API instance + this._offers = new RondevuOffers( + this.baseUrl, + this.credentials, + this.fetchFn + ); + + return this.credentials; } /** - * Update the peer ID (useful when user identity changes) + * Check if client is authenticated */ - updatePeerId(newPeerId: string): void { - (this as any).peerId = newPeerId; + isAuthenticated(): boolean { + return !!this.credentials; } /** - * Create an offer (offerer role) - * @param id - Offer identifier (custom code) - * @returns Promise that resolves to RondevuConnection + * Get current credentials */ - async offer(id: string): Promise { - // Create peer connection - const pc = new this.RTCPeerConnection(this.rtcConfig); - - // Create initial data channel for negotiation (required for offer creation) - pc.createDataChannel('_negotiation'); - - // Generate offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - // Wait for ICE gathering to complete - await this.waitForIceGathering(pc); - - // Create offer on server with custom code - await this.api.createOffer({ - peerId: this.peerId, - offer: pc.localDescription!.sdp, - code: id, - }); - - // Create connection object - const connectionParams: RondevuConnectionParams = { - id, - role: 'offerer', - pc, - localPeerId: this.peerId, - remotePeerId: '', // Will be populated when answer is received - pollingInterval: this.pollingInterval, - connectionTimeout: this.connectionTimeout, - wrtc: this.wrtc, - }; - - const connection = new RondevuConnection(connectionParams, this.api); - - // Start polling for answer - connection.startPolling(); - - return connection; + getCredentials(): Credentials | undefined { + return this.credentials; } /** - * Answer an existing offer by ID (answerer role) - * @param id - Offer code - * @returns Promise that resolves to RondevuConnection + * Create a new WebRTC connection (requires authentication) + * This is a high-level helper that creates and manages WebRTC connections + * + * @param rtcConfig Optional RTCConfiguration for the peer connection + * @returns RondevuConnection instance */ - async answer(id: string): Promise { - // Poll server to get offer by ID - const offerData = await this.findOfferById(id); - - if (!offerData) { - throw new Error(`Offer ${id} not found or expired`); + createConnection(rtcConfig?: RTCConfiguration): RondevuConnection { + if (!this._offers) { + throw new Error('Not authenticated. Call register() first or provide credentials.'); } - // Create peer connection - const pc = new this.RTCPeerConnection(this.rtcConfig); - - // Set remote offer - await pc.setRemoteDescription({ - type: 'offer', - sdp: offerData.offer, - }); - - // Generate answer - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - // Wait for ICE gathering - await this.waitForIceGathering(pc); - - // Send answer to server - await this.api.sendAnswer({ - code: id, - answer: pc.localDescription!.sdp, - side: 'answerer', - }); - - // Create connection object - const connectionParams: RondevuConnectionParams = { - id, - role: 'answerer', - pc, - localPeerId: this.peerId, - remotePeerId: '', // Will be determined from peerId in offer - pollingInterval: this.pollingInterval, - connectionTimeout: this.connectionTimeout, - wrtc: this.wrtc, - }; - - const connection = new RondevuConnection(connectionParams, this.api); - - // Start polling for ICE candidates - connection.startPolling(); - - return connection; - } - - /** - * Wait for ICE gathering to complete - */ - private async waitForIceGathering(pc: RTCPeerConnection): Promise { - if (pc.iceGatheringState === 'complete') { - return; - } - - return new Promise((resolve) => { - const checkState = () => { - if (pc.iceGatheringState === 'complete') { - pc.removeEventListener('icegatheringstatechange', checkState); - resolve(); - } - }; - - pc.addEventListener('icegatheringstatechange', checkState); - - // Also set a timeout in case gathering takes too long - setTimeout(() => { - pc.removeEventListener('icegatheringstatechange', checkState); - resolve(); - }, 5000); - }); - } - - /** - * Find an offer by code - */ - private async findOfferById(id: string): Promise<{ - offer: string; - } | null> { - try { - // Poll for the offer directly - const response = await this.api.poll(id, 'answerer'); - const answererResponse = response as { offer: string; offerCandidates: string[] }; - - if (answererResponse.offer) { - return { - offer: answererResponse.offer, - }; - } - - return null; - } catch (err) { - throw new Error(`Failed to find offer ${id}: ${(err as Error).message}`); - } + return new RondevuConnection(this._offers, rtcConfig); } }