Add WebRTC connection manager and fix race condition

- Add RondevuConnection class for high-level WebRTC management
- Handles offer/answer exchange, ICE candidates, and data channels
- Fix race condition in answer() method (register answerer before sending ICE)
- Add event-driven API (connecting, connected, disconnected, error, datachannel, track)
- Update README with connection manager examples
- Export new connection types and classes

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-14 18:30:47 +01:00
parent e1ca8e1c16
commit 5a47e0a397
8 changed files with 1279 additions and 598 deletions

507
README.md
View File

@@ -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

View File

@@ -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",

60
src/auth.ts Normal file
View File

@@ -0,0 +1,60 @@
export interface Credentials {
peerId: string;
secret: string;
}
// Fetch-compatible function type
export type FetchFunction = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Response>;
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<Credentials> {
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}`;
}
}

83
src/bloom.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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<string, RTCDataChannel>;
private pollingInterval?: ReturnType<typeof setInterval>;
private pollingIntervalMs: number;
private connectionTimeoutMs: number;
private connectionTimer?: ReturnType<typeof setTimeout>;
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<typeof setInterval>;
private answerPollingInterval?: ReturnType<typeof setInterval>;
private lastIceTimestamp: number = Date.now();
private eventListeners: Map<keyof RondevuConnectionEvents, Set<Function>> = 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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<K extends keyof RondevuConnectionEvents>(
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<K extends keyof RondevuConnectionEvents>(
event: K,
listener: RondevuConnectionEvents[K]
): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.delete(listener);
}
}
/**
* Emit event
*/
private emit<K extends keyof RondevuConnectionEvents>(
event: K,
...args: Parameters<RondevuConnectionEvents[K]>
): 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();
}
}

View File

@@ -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';

325
src/offers.ts Normal file
View File

@@ -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<Offer[]> {
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<Offer[]> {
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<Offer[]> {
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<void> {
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<void> {
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<void> {
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<Array<{
offerId: string;
answererId: string;
sdp: string;
answeredAt: number;
topics: string[];
}>> {
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<void> {
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<IceCandidate[]> {
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;
}
}

View File

@@ -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<void> {
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<Credentials> {
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<RondevuConnection> {
// 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<RondevuConnection> {
// 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<void> {
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);
}
}