28 Commits

Author SHA1 Message Date
14d3f943da 0.4.1 2025-11-14 20:46:47 +01:00
2989326a50 Fix: Initialize lastIceTimestamp to 0 to get all candidates
Critical bug fix: lastIceTimestamp was initialized to Date.now(),
causing the first poll to miss early ICE candidates that were sent
before polling started. This resulted in ICE failure.

Now initializes to 0 so the first poll retrieves ALL candidates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:46:47 +01:00
7b82f963a3 Docs: Make WebRTC Connection Manager the main example
- Moved RondevuConnection examples to Quick Start section
- Reorganized platform-specific setup into dedicated section
- Moved low-level API to separate section for advanced users

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:59:11 +01:00
d25141a765 Fix: Stop polling when offer expires or connection succeeds
- Stop polling when 404 error (offer not found/expired)
- Stop polling once connection state is 'connected'
- Prevents unnecessary API calls and console errors
- Improves resource cleanup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:47:38 +01:00
9d9aba2cf5 Refactor: Send/receive ICE candidates as complete objects
- Update to send full RTCIceCandidateInit objects instead of partial data
- Simplify API by using JSON serialization
- Include usernameFragment field
- More maintainable and future-proof

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:38:41 +01:00
dd64a565aa Fix ICE candidate handling - send full candidate objects
- Update IceCandidate interface to include sdpMid and sdpMLineIndex
- Update addIceCandidates to accept full candidate objects
- Update connection manager to send and receive complete ICE data
- Fixes 'Either sdpMid or sdpMLineIndex must be specified' error

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:31:30 +01:00
cd78a16c66 Improve trickle ICE with early candidate buffering
- Buffer ICE candidates generated before offerId is set
- Flush buffered candidates immediately after offerId is set
- Continue sending candidates as they arrive (true trickle ICE)
- Prevents losing early ICE candidates during setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:01:49 +01:00
c202e1c627 Update API URL to api.ronde.vu in examples
- Change all examples from rondevu.xtrdev.workers.dev to api.ronde.vu
- Update default baseUrl in documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:54:41 +01:00
f6004a9bc0 Fix ICE candidate handling in connection manager
- Remove hardcoded sdpMLineIndex and sdpMid values
- Create RTCIceCandidate properly from candidate string
- Let WebRTC parse candidate metadata automatically
- Fixes ICE connection failures

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:37:50 +01:00
5a47e0a397 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>
2025-11-14 18:30:47 +01:00
e1ca8e1c16 Fix multiple connect events and add leave method
- Add hasConnected flag to prevent duplicate connect events
- Add leave() method to RondevuConnection to end sessions
- Add leave() API method to call /leave endpoint
- Version 0.3.5

The connect event will now only fire once per connection,
fixing the issue where it could fire multiple times as the
WebRTC connectionState transitions.

The leave() method allows either peer to end the session
by deleting the offer from the server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 00:05:29 +01:00
2f47107018 Remove origin bucketing feature
- Remove origin option from client configuration
- Simplify API client to not send Origin header
- Version 0.3.4

Origin-based session isolation has been removed from the server,
so this feature is no longer needed on the client side.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:59:01 +01:00
d200d73cd9 Rename methods to match WebRTC terminology
- Rename create() → offer() to align with WebRTC offer creation
- Rename connect() → answer() to align with WebRTC answer handling
- Update README with new method names and examples
- Version 0.3.3

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:29:04 +01:00
c8e5e4d17a Simplify client: remove topics, ID-based connections only
- Remove join(), listTopics(), listSessions() methods
- Simplify to just create(id) and connect(id)
- Remove topic-related types and interfaces
- Add automatic version checking against server
- Update README with simplified API
- Client version: 0.3.2

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:17:51 +01:00
6466a6f52a Update default API URL to api.ronde.vu
- Changed default baseUrl from rondevu.xtrdev.workers.dev to api.ronde.vu
- Updated JSDoc comment for baseUrl in RondevuOptions
- Version bumped to 0.3.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:21:27 +01:00
2e4d0d6a54 Add Node.js support via WebRTC polyfill injection
- Added WebRTCPolyfill interface for injecting WebRTC implementations
- Added wrtc option to RondevuOptions and RondevuConnectionParams
- Updated Rondevu and RondevuConnection to use injected APIs
- Added helpful error message when RTCPeerConnection is not available
- Updated README with Node.js usage examples
- Version bumped to 0.3.0

Fixes: RTCPeerConnection not defined error in Node.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:07:01 +01:00
2b73e6ba44 Fix ES module imports by adding .js extensions
- Added .js extensions to all import statements in source files
- Fixes ERR_MODULE_NOT_FOUND error when using package with Node.js
- Version bumped to 0.2.2

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:56:18 +01:00
a893c7d040 Rename RondevuClient to RondevuAPI and integrate into Rondevu class
- Renamed RondevuClient class to RondevuAPI for clarity
- Integrated API as public property `api` on Rondevu class
- Updated all internal references from `client` to `api`
- Updated all example code in documentation
- Removed recursive publish script from package.json
- Bumped version to 0.2.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:51:11 +01:00
35dc5aee36 0.1.2 2025-11-08 11:55:27 +01:00
b6129294c4 0.1.1 2025-11-08 11:53:13 +01:00
649a8d5d3f Update client to use /topics endpoint and add getVersion method
- Update listTopics() to use /topics endpoint instead of /
- Add getVersion() method to fetch server version information
- Add VersionResponse type and export it

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 11:35:00 +01:00
2065aecc6a Add link to rondevu-demo repository
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 10:55:59 +01:00
5e98610e30 Add cross-links between related repositories
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 10:53:41 +01:00
2c3f8ef22b Remove global origin option and update README
- Remove origin option from RondevuClientOptions and RondevuOptions
- Remove ConnectOptions interface with global flag
- Remove all customHeaders logic for origin override
- Update README with consistent Rondevu branding

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 10:52:56 +01:00
7831e03af1 Add RTC configuration example to README
- Demonstrate how to configure `rtcConfig` with ICE servers in Rondevu constructor.
2025-11-08 10:40:53 +01:00
9df9966381 Replace origin override with global option
- Remove origin parameter from connect() method
- Add ConnectOptions interface with global flag
- When global: true, sends X-Rondevu-Global header instead of trying to override Origin
- Update client methods to accept customHeaders parameter
- Pass custom headers through connection polling and ICE candidate exchange
- Bump version to 0.1.0

This change works around browser restriction on Origin header modification.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:45:52 +01:00
de6244cf24 Bump version to 0.0.6 and add publish script to package.json 2025-11-07 23:30:14 +01:00
33ecb9f9bc Add default baseUrl and origin override support
- Set default baseUrl to 'https://rondevu.xtrdev.workers.dev' in RondevuOptions
- Make baseUrl optional in Rondevu constructor
- Add optional origin parameter to connect() method for per-connection origin override
- Bump version to 0.0.5

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:29:30 +01:00
10 changed files with 1423 additions and 709 deletions

463
README.md
View File

@@ -1,60 +1,453 @@
# Rondevu # @xtr-dev/rondevu-client
🎯 Meet WebRTC peers by topic, by peer ID, or by connection ID.
## @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) [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
TypeScript Rondevu HTTP and WebRTC client, for simple peer discovery and connection. 🌐 **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 ```bash
npm install @xtr-dev/rondevu-client npm install @xtr-dev/rondevu-client
``` ```
### Usage ## Quick Start
The easiest way to use Rondevu is with the high-level `RondevuConnection` class, which handles all WebRTC connection complexity including offer/answer exchange, ICE candidates, and connection lifecycle.
### Creating an Offer (Peer A)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client';
const rdv = new Rondevu({ baseUrl: 'https://server.com' }); const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Connect by topic // Create a connection
const conn = await rdv.join('room'); const conn = client.createConnection();
// Or connect by ID // Set up event listeners
const conn = await rdv.connect('meeting-123'); conn.on('connected', () => {
console.log('Connected to peer!');
});
// Use the connection conn.on('datachannel', (channel) => {
conn.on('connect', () => { console.log('Data channel ready');
const channel = conn.dataChannel('chat');
channel.send('Hello!'); channel.onmessage = (event) => {
console.log('Received:', event.data);
};
channel.send('Hello from peer A!');
});
// 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://api.ronde.vu' });
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;
}); });
``` ```
### API ### Adding Media Tracks
**Main Methods:** ```typescript
- `rdv.join(topic)` - Auto-connect to first peer in topic // Get user's camera/microphone
- `rdv.join(topic, {filter})` - Connect to specific peer by ID const stream = await navigator.mediaDevices.getUserMedia({
- `rdv.create(id, topic)` - Create connection for others to join video: true,
- `rdv.connect(id)` - Join connection by ID audio: true
});
**Connection Events:** // Add tracks to connection
- `connect` - Connection established stream.getTracks().forEach(track => {
- `disconnect` - Connection closed conn.addTrack(track, stream);
- `datachannel` - Remote peer created data channel });
- `stream` - Remote media stream received ```
- `error` - Error occurred
**Connection Methods:** ### Connection Properties
- `conn.dataChannel(label)` - Get or create data channel
- `conn.addStream(stream)` - Add media stream
- `conn.getPeerConnection()` - Get underlying RTCPeerConnection
- `conn.close()` - Close connection
### License ```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();
```
## Platform-Specific Setup
### Node.js 18+ (with native fetch)
Works out of the box - no additional setup needed.
### Node.js < 18 (without native fetch)
Install node-fetch and provide it to the client:
```bash
npm install node-fetch
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any
});
```
### Deno
```typescript
import { Rondevu } from 'npm:@xtr-dev/rondevu-client';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
```
### Bun
Works out of the box - no additional setup needed.
### Cloudflare Workers
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
export default {
async fetch(request: Request, env: Env) {
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
const creds = await client.register();
return new Response(JSON.stringify(creds));
}
};
```
## Low-Level API Usage
For advanced use cases where you need direct control over the signaling process, you can use the low-level API:
```typescript
import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
// 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));
// 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
}]);
// Discover peers by topic
const discovered = await client.offers.findByTopic('movie-xyz', {
limit: 50
});
console.log(`Found ${discovered.length} peers`);
// 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
});
```
## 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 fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
fetch: fetch as any
});
```
## License
MIT MIT

View File

@@ -1,7 +1,7 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.0.4", "version": "0.4.1",
"description": "TypeScript client for Rondevu peer signaling and discovery server", "description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "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,7 +1,5 @@
import { import {
RondevuClientOptions, RondevuClientOptions,
ListTopicsResponse,
ListSessionsResponse,
CreateOfferRequest, CreateOfferRequest,
CreateOfferResponse, CreateOfferResponse,
AnswerRequest, AnswerRequest,
@@ -9,26 +7,25 @@ import {
PollRequest, PollRequest,
PollOffererResponse, PollOffererResponse,
PollAnswererResponse, PollAnswererResponse,
VersionResponse,
HealthResponse, HealthResponse,
ErrorResponse, ErrorResponse,
Side, Side,
} from './types'; } from './types.js';
/** /**
* HTTP client for Rondevu peer signaling and discovery server * HTTP API client for Rondevu peer signaling server
*/ */
export class RondevuClient { export class RondevuAPI {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly origin: string;
private readonly fetchImpl: typeof fetch; private readonly fetchImpl: typeof fetch;
/** /**
* Creates a new Rondevu client instance * Creates a new Rondevu API client instance
* @param options - Client configuration options * @param options - Client configuration options
*/ */
constructor(options: RondevuClientOptions) { constructor(options: RondevuClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.origin = options.origin || new URL(this.baseUrl).origin;
this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis); this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis);
} }
@@ -42,7 +39,6 @@ export class RondevuClient {
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Origin': this.origin,
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
@@ -66,98 +62,67 @@ export class RondevuClient {
} }
/** /**
* Lists all topics with peer counts * Gets server version information
* *
* @param page - Page number (starting from 1) * @returns Server version
* @param limit - Results per page (max 1000)
* @returns List of topics with pagination info
* *
* @example * @example
* ```typescript * ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { topics, pagination } = await client.listTopics(); * const { version } = await api.getVersion();
* console.log(`Found ${topics.length} topics`); * console.log('Server version:', version);
* ``` * ```
*/ */
async listTopics(page = 1, limit = 100): Promise<ListTopicsResponse> { async getVersion(): Promise<VersionResponse> {
const params = new URLSearchParams({ return this.request<VersionResponse>('/', {
page: page.toString(),
limit: limit.toString(),
});
return this.request<ListTopicsResponse>(`/?${params}`, {
method: 'GET', method: 'GET',
}); });
} }
/** /**
* Discovers available peers for a given topic * Creates a new offer
* *
* @param topic - Topic identifier * @param request - Offer details including peer ID, signaling data, and optional custom code
* @returns List of available sessions * @returns Unique offer code (UUID or custom code)
* *
* @example * @example
* ```typescript * ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { sessions } = await client.listSessions('my-room'); * const { code } = await api.createOffer({
* const otherPeers = sessions.filter(s => s.peerId !== myPeerId);
* ```
*/
async listSessions(topic: string): Promise<ListSessionsResponse> {
return this.request<ListSessionsResponse>(`/${encodeURIComponent(topic)}/sessions`, {
method: 'GET',
});
}
/**
* Announces peer availability and creates a new session
*
* @param topic - Topic identifier for grouping peers (max 1024 characters)
* @param request - Offer details including peer ID and signaling data
* @returns Unique session code (UUID)
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { code } = await client.createOffer('my-room', {
* peerId: 'peer-123', * peerId: 'peer-123',
* offer: signalingData * offer: signalingData,
* code: 'my-custom-code' // optional
* }); * });
* console.log('Session code:', code); * console.log('Offer code:', code);
* ``` * ```
*/ */
async createOffer( async createOffer(request: CreateOfferRequest): Promise<CreateOfferResponse> {
topic: string, return this.request<CreateOfferResponse>('/offer', {
request: CreateOfferRequest method: 'POST',
): Promise<CreateOfferResponse> { body: JSON.stringify(request),
return this.request<CreateOfferResponse>( });
`/${encodeURIComponent(topic)}/offer`,
{
method: 'POST',
body: JSON.stringify(request),
}
);
} }
/** /**
* Sends an answer or candidate to an existing session * Sends an answer or candidate to an existing offer
* *
* @param request - Answer details including session code and signaling data * @param request - Answer details including offer code and signaling data
* @returns Success confirmation * @returns Success confirmation
* *
* @example * @example
* ```typescript * ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* *
* // Send answer * // Send answer
* await client.sendAnswer({ * await api.sendAnswer({
* code: sessionCode, * code: offerCode,
* answer: answerData, * answer: answerData,
* side: 'answerer' * side: 'answerer'
* }); * });
* *
* // Send candidate * // Send candidate
* await client.sendAnswer({ * await api.sendAnswer({
* code: sessionCode, * code: offerCode,
* candidate: candidateData, * candidate: candidateData,
* side: 'offerer' * side: 'offerer'
* }); * });
@@ -171,24 +136,24 @@ export class RondevuClient {
} }
/** /**
* Polls for session data from the other peer * Polls for offer data from the other peer
* *
* @param code - Session UUID * @param code - Offer code
* @param side - Which side is polling ('offerer' or 'answerer') * @param side - Which side is polling ('offerer' or 'answerer')
* @returns Session data including offers, answers, and candidates * @returns Offer data including offers, answers, and candidates
* *
* @example * @example
* ```typescript * ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* *
* // Offerer polls for answer * // Offerer polls for answer
* const offererData = await client.poll(sessionCode, 'offerer'); * const offererData = await api.poll(offerCode, 'offerer');
* if (offererData.answer) { * if (offererData.answer) {
* console.log('Received answer:', offererData.answer); * console.log('Received answer:', offererData.answer);
* } * }
* *
* // Answerer polls for offer * // Answerer polls for offer
* const answererData = await client.poll(sessionCode, 'answerer'); * const answererData = await api.poll(offerCode, 'answerer');
* console.log('Received offer:', answererData.offer); * console.log('Received offer:', answererData.offer);
* ``` * ```
*/ */
@@ -204,15 +169,16 @@ export class RondevuClient {
} }
/** /**
* Checks server health * Checks server health and version
* *
* @returns Health status and timestamp * @returns Health status, timestamp, and version
* *
* @example * @example
* ```typescript * ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const health = await client.health(); * const health = await api.health();
* console.log('Server status:', health.status); * console.log('Server status:', health.status);
* console.log('Server version:', health.version);
* ``` * ```
*/ */
async health(): Promise<HealthResponse> { async health(): Promise<HealthResponse> {
@@ -220,4 +186,23 @@ export class RondevuClient {
method: 'GET', method: 'GET',
}); });
} }
/**
* Ends a session by deleting the offer from the server
*
* @param code - The offer code
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* await api.leave('my-offer-code');
* ```
*/
async leave(code: string): Promise<{ success: boolean }> {
return this.request<{ success: boolean }>('/leave', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
} }

View File

@@ -1,310 +1,388 @@
import { EventEmitter } from './event-emitter'; import { RondevuOffers, RTCIceCandidateInit } from './offers.js';
import { RondevuClient } from './client';
import { RondevuConnectionParams } from './types';
/** /**
* Represents a WebRTC connection with automatic signaling and ICE exchange * Events emitted by RondevuConnection
*/ */
export class RondevuConnection extends EventEmitter { export interface RondevuConnectionEvents {
readonly id: string; 'connecting': () => void;
readonly topic: string; 'connected': () => void;
readonly role: 'offerer' | 'answerer'; 'disconnected': () => void;
readonly remotePeerId: string; '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 pc: RTCPeerConnection;
private client: RondevuClient; private offersApi: RondevuOffers;
private localPeerId: string; private offerId?: string;
private dataChannels: Map<string, RTCDataChannel>; private role?: 'offerer' | 'answerer';
private pollingInterval?: ReturnType<typeof setInterval>; private icePollingInterval?: ReturnType<typeof setInterval>;
private pollingIntervalMs: number; private answerPollingInterval?: ReturnType<typeof setInterval>;
private connectionTimeoutMs: number; private lastIceTimestamp: number = 0; // Start at 0 to get all candidates on first poll
private connectionTimer?: ReturnType<typeof setTimeout>; private eventListeners: Map<keyof RondevuConnectionEvents, Set<Function>> = new Map();
private isPolling: boolean = false; private dataChannel?: RTCDataChannel;
private isClosed: boolean = false; private pendingIceCandidates: RTCIceCandidateInit[] = [];
constructor(params: RondevuConnectionParams, client: RondevuClient) { /**
super(); * Current connection state
this.id = params.id; */
this.topic = params.topic; get connectionState(): RTCPeerConnectionState {
this.role = params.role; return this.pc.connectionState;
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.setupEventHandlers();
this.startConnectionTimeout();
} }
/** /**
* Setup RTCPeerConnection event handlers * The offer ID for this connection
*/ */
private setupEventHandlers(): void { get id(): string | undefined {
// ICE candidate gathering return this.offerId;
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}`));
});
}
};
// Connection state changes /**
this.pc.onconnectionstatechange = () => { * Get the primary data channel (if created)
this.handleConnectionStateChange(); */
}; get channel(): RTCDataChannel | undefined {
return this.dataChannel;
}
// Remote data channels constructor(
this.pc.ondatachannel = (event) => { offersApi: RondevuOffers,
this.handleRemoteDataChannel(event.channel); 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();
}
// Remote media streams /**
this.pc.ontrack = (event) => { * Set up peer connection event handlers
if (event.streams && event.streams[0]) { */
this.emit('stream', event.streams[0]); private setupPeerConnection(): void {
} this.pc.onicecandidate = async (event) => {
}; if (event.candidate) {
// Convert RTCIceCandidate to RTCIceCandidateInit (plain object)
const candidateData: RTCIceCandidateInit = {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment,
};
// ICE connection state changes if (this.offerId) {
this.pc.oniceconnectionstatechange = () => { // offerId is set, send immediately (trickle ICE)
const state = this.pc.iceConnectionState; try {
await this.offersApi.addIceCandidates(this.offerId, [candidateData]);
if (state === 'failed' || state === 'closed') { } catch (err) {
this.emit('error', new Error(`ICE connection ${state}`)); console.error('Error sending ICE candidate:', err);
if (state === 'failed') { }
this.close(); } else {
// offerId not set yet, buffer the candidate
this.pendingIceCandidates.push(candidateData);
} }
} }
}; };
this.pc.onconnectionstatechange = () => {
switch (this.pc.connectionState) {
case 'connecting':
this.emit('connecting');
break;
case 'connected':
this.emit('connected');
// Stop polling once connected - we have all the ICE candidates we need
this.stopPolling();
break;
case 'disconnected':
case 'failed':
case 'closed':
this.emit('disconnected');
this.stopPolling();
break;
}
};
this.pc.ondatachannel = (event) => {
this.dataChannel = event.channel;
this.emit('datachannel', event.channel);
};
this.pc.ontrack = (event) => {
this.emit('track', event);
};
this.pc.onicecandidateerror = (event) => {
console.error('ICE candidate error:', event);
};
} }
/** /**
* Handle RTCPeerConnection state changes * Flush buffered ICE candidates (trickle ICE support)
*/ */
private handleConnectionStateChange(): void { private async flushPendingIceCandidates(): Promise<void> {
const state = this.pc.connectionState; if (this.pendingIceCandidates.length > 0 && this.offerId) {
try {
switch (state) { await this.offersApi.addIceCandidates(this.offerId, this.pendingIceCandidates);
case 'connected': this.pendingIceCandidates = [];
this.clearConnectionTimeout(); } catch (err) {
this.stopPolling(); console.error('Error flushing pending ICE candidates:', err);
this.emit('connect'); }
break;
case 'disconnected':
this.emit('disconnect');
break;
case 'failed':
this.emit('error', new Error('Connection failed'));
this.close();
break;
case 'closed':
this.emit('disconnect');
break;
} }
} }
/** /**
* 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> { async createOffer(options: ConnectionOptions): Promise<string> {
try { this.role = 'offerer';
await this.client.sendAnswer({
code: this.id, // Create data channel if requested
candidate: JSON.stringify(candidate.toJSON()), if (options.createDataChannel !== false) {
side: this.role, this.dataChannel = this.pc.createDataChannel(
}); options.dataChannelLabel || 'data'
} catch (err: any) { );
throw new Error(`Failed to send ICE candidate: ${err.message}`); 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;
// Flush any ICE candidates that were generated during offer creation
await this.flushPendingIceCandidates();
// Start polling for answers
this.startAnswerPolling();
return this.offerId;
} }
/** /**
* Start polling for remote session data (answer/candidates) * Answer an existing offer
*/ */
startPolling(): void { async answer(offerId: string, offerSdp: string): Promise<void> {
if (this.isPolling || this.isClosed) { this.role = 'answerer';
return;
}
this.isPolling = true; // Set remote description
await this.pc.setRemoteDescription({
// Poll immediately type: 'offer',
this.poll().catch((err) => { sdp: offerSdp
this.emit('error', new Error(`Poll error: ${err.message}`));
}); });
// Set up interval polling // Create answer
this.pollingInterval = setInterval(() => { const answer = await this.pc.createAnswer();
this.poll().catch((err) => { await this.pc.setLocalDescription(answer);
this.emit('error', new Error(`Poll error: ${err.message}`));
}); // Send answer to server FIRST
}, this.pollingIntervalMs); // 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;
// Flush any ICE candidates that were generated during answer creation
await this.flushPendingIceCandidates();
// Start polling for ICE candidates
this.startIcePolling();
} }
/** /**
* Stop polling * Start polling for answers (offerer only)
*/ */
private stopPolling(): void { private startAnswerPolling(): void {
this.isPolling = false; if (this.role !== 'offerer' || !this.offerId) return;
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = undefined;
}
}
/** this.answerPollingInterval = setInterval(async () => {
* Poll the signaling server for remote data try {
*/ const answers = await this.offersApi.getAnswers();
private async poll(): Promise<void> { const myAnswer = answers.find(a => a.offerId === this.offerId);
if (this.isClosed) {
this.stopPolling();
return;
}
try { if (myAnswer) {
const response = await this.client.poll(this.id, this.role); // Set remote description
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) {
await this.pc.setRemoteDescription({ await this.pc.setRemoteDescription({
type: 'answer', type: 'answer',
sdp: offererResponse.answer, sdp: myAnswer.sdp
}); });
}
// Apply ICE candidates // Stop answer polling, start ICE polling
if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) { this.stopAnswerPolling();
for (const candidateStr of offererResponse.answerCandidates) { this.startIcePolling();
try {
const candidate = JSON.parse(candidateStr);
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
}
}
} }
} else { } catch (err) {
// Answerer role console.error('Error polling for answers:', err);
const answererResponse = response as { offer: string; offerCandidates: string[] }; // Stop polling if offer expired/not found
if (err instanceof Error && err.message.includes('not found')) {
// Apply ICE candidates from offerer this.stopPolling();
if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) {
for (const candidateStr of answererResponse.offerCandidates) {
try {
const candidate = JSON.parse(candidateStr);
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
}
}
} }
} }
} catch (err: any) { }, 2000);
// 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(); * 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 cand of candidates) {
// Use the candidate object directly - it's already RTCIceCandidateInit
await this.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
this.lastIceTimestamp = cand.createdAt;
}
} catch (err) {
console.error('Error polling for ICE candidates:', err);
// Stop polling if offer expired/not found
if (err instanceof Error && err.message.includes('not found')) {
this.stopPolling();
}
} }
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 { private stopIcePolling(): void {
this.dataChannels.set(channel.label, channel); if (this.icePollingInterval) {
this.emit('datachannel', channel); clearInterval(this.icePollingInterval);
} this.icePollingInterval = undefined;
/**
* 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;
} }
} }
/** /**
* Close the connection and cleanup resources * Stop all polling
*/
private stopPolling(): void {
this.stopAnswerPolling();
this.stopIcePolling();
}
/**
* 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 { close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.stopPolling(); this.stopPolling();
this.clearConnectionTimeout(); this.pc.close();
this.eventListeners.clear();
// 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');
} }
} }

View File

@@ -1,41 +1,31 @@
/** /**
* @xtr-dev/rondevu-client * @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'; export { Rondevu } from './rondevu.js';
export type { RondevuOptions } from './rondevu.js';
// Export connection class // Export authentication
export { RondevuConnection } from './connection'; export { RondevuAuth } from './auth.js';
export type { Credentials, FetchFunction } from './auth.js';
// Export low-level signaling client (for advanced usage) // Export offers API
export { RondevuClient } from './client'; export { RondevuOffers } from './offers.js';
// Export all types
export type { export type {
// WebRTC types
RondevuOptions,
JoinOptions,
ConnectionRole,
RondevuConnectionParams,
RondevuConnectionEvents,
// Signaling types
Side,
Session,
TopicInfo,
Pagination,
ListTopicsResponse,
ListSessionsResponse,
CreateOfferRequest, CreateOfferRequest,
CreateOfferResponse, Offer,
AnswerRequest, IceCandidate,
AnswerResponse, TopicInfo
PollRequest, } from './offers.js';
PollOffererResponse,
PollAnswererResponse, // Export bloom filter
PollResponse, export { BloomFilter } from './bloom.js';
HealthResponse,
ErrorResponse, // Export connection manager
RondevuClientOptions, export { RondevuConnection } from './connection.js';
} from './types'; export type {
ConnectionOptions,
RondevuConnectionEvents
} from './connection.js';

338
src/offers.ts Normal file
View File

@@ -0,0 +1,338 @@
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;
}
/**
* RTCIceCandidateInit interface for environments without native WebRTC types
*/
export interface RTCIceCandidateInit {
candidate?: string;
sdpMid?: string | null;
sdpMLineIndex?: number | null;
usernameFragment?: string | null;
}
export interface IceCandidate {
candidate: RTCIceCandidateInit; // Full candidate object
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: RTCIceCandidateInit[]
): 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,266 +1,103 @@
import { RondevuClient } from './client'; import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuConnection } from './connection'; import { RondevuOffers } from './offers.js';
import { RondevuOptions, JoinOptions, RondevuConnectionParams } from './types'; import { RondevuConnection, ConnectionOptions } from './connection.js';
export interface RondevuOptions {
/**
* Base URL of the Rondevu server
* @default 'https://api.ronde.vu'
*/
baseUrl?: string;
/**
* 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;
}
/**
* Main Rondevu WebRTC client with automatic connection management
*/
export class Rondevu { export class Rondevu {
readonly peerId: string; readonly auth: RondevuAuth;
private _offers?: RondevuOffers;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
private client: RondevuClient; constructor(options: RondevuOptions = {}) {
private rtcConfig?: RTCConfiguration; this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
private pollingInterval: number; this.fetchFn = options.fetch;
private connectionTimeout: number;
/** this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
* Creates a new Rondevu client instance
* @param options - Client configuration options
*/
constructor(options: RondevuOptions) {
this.client = new RondevuClient({
baseUrl: options.baseUrl,
origin: options.origin,
fetch: options.fetch,
});
// Auto-generate peer ID if not provided if (options.credentials) {
this.peerId = options.peerId || this.generatePeerId(); this.credentials = options.credentials;
this.rtcConfig = options.rtcConfig; this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
this.pollingInterval = options.pollingInterval || 1000;
this.connectionTimeout = options.connectionTimeout || 30000;
}
/**
* Generate a unique peer ID
*/
private generatePeerId(): string {
return `rdv_${Math.random().toString(36).substring(2, 14)}`;
}
/**
* Update the peer ID (useful when user identity changes)
*/
updatePeerId(newPeerId: string): void {
(this as any).peerId = newPeerId;
}
/**
* Create a new connection (offerer role)
* @param id - Connection identifier
* @param topic - Topic name for grouping connections
* @returns Promise that resolves to RondevuConnection
*/
async create(id: string, topic: string): Promise<RondevuConnection> {
// Create peer connection
const pc = new 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 session on server with custom code
await this.client.createOffer(topic, {
peerId: this.peerId,
offer: pc.localDescription!.sdp,
code: id,
});
// Create connection object
const connectionParams: RondevuConnectionParams = {
id,
topic,
role: 'offerer',
pc,
localPeerId: this.peerId,
remotePeerId: '', // Will be populated when answer is received
pollingInterval: this.pollingInterval,
connectionTimeout: this.connectionTimeout,
};
const connection = new RondevuConnection(connectionParams, this.client);
// Start polling for answer
connection.startPolling();
return connection;
}
/**
* Connect to an existing connection by ID (answerer role)
* @param id - Connection identifier
* @returns Promise that resolves to RondevuConnection
*/
async connect(id: string): Promise<RondevuConnection> {
// Poll server to get session by ID
const sessionData = await this.findSessionById(id);
if (!sessionData) {
throw new Error(`Connection ${id} not found or expired`);
} }
// Create peer connection
const pc = new RTCPeerConnection(this.rtcConfig);
// Set remote offer
await pc.setRemoteDescription({
type: 'offer',
sdp: sessionData.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.client.sendAnswer({
code: id,
answer: pc.localDescription!.sdp,
side: 'answerer',
});
// Create connection object
const connectionParams: RondevuConnectionParams = {
id,
topic: sessionData.topic || 'unknown',
role: 'answerer',
pc,
localPeerId: this.peerId,
remotePeerId: sessionData.peerId,
pollingInterval: this.pollingInterval,
connectionTimeout: this.connectionTimeout,
};
const connection = new RondevuConnection(connectionParams, this.client);
// Start polling for ICE candidates
connection.startPolling();
return connection;
} }
/** /**
* Join a topic and discover available peers (answerer role) * Get offers API (requires authentication)
* @param topic - Topic name
* @param options - Optional join options for filtering and selection
* @returns Promise that resolves to RondevuConnection
*/ */
async join(topic: string, options?: JoinOptions): Promise<RondevuConnection> { get offers(): RondevuOffers {
// List sessions in topic if (!this._offers) {
const { sessions } = await this.client.listSessions(topic); throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._offers;
}
// Filter out self (sessions with our peer ID) /**
let availableSessions = sessions.filter( * Register and initialize authenticated client
session => session.peerId !== this.peerId */
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create offers API instance
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
); );
// Apply custom filter if provided return this.credentials;
if (options?.filter) {
availableSessions = availableSessions.filter(options.filter);
}
if (availableSessions.length === 0) {
throw new Error(`No available peers in topic: ${topic}`);
}
// Select session based on strategy
const selectedSession = this.selectSession(
availableSessions,
options?.select || 'first'
);
// Connect to selected session
return this.connect(selectedSession.code);
} }
/** /**
* Select a session based on strategy * Check if client is authenticated
*/ */
private selectSession( isAuthenticated(): boolean {
sessions: Array<{ code: string; peerId: string; createdAt: number }>, return !!this.credentials;
strategy: 'first' | 'newest' | 'oldest' | 'random'
): { code: string; peerId: string; createdAt: number } {
switch (strategy) {
case 'first':
return sessions[0];
case 'newest':
return sessions.reduce((newest, session) =>
session.createdAt > newest.createdAt ? session : newest
);
case 'oldest':
return sessions.reduce((oldest, session) =>
session.createdAt < oldest.createdAt ? session : oldest
);
case 'random':
return sessions[Math.floor(Math.random() * sessions.length)];
default:
return sessions[0];
}
} }
/** /**
* Wait for ICE gathering to complete * Get current credentials
*/ */
private async waitForIceGathering(pc: RTCPeerConnection): Promise<void> { getCredentials(): Credentials | undefined {
if (pc.iceGatheringState === 'complete') { return this.credentials;
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 a session by connection ID * Create a new WebRTC connection (requires authentication)
* This requires polling since we don't know which topic it's in * This is a high-level helper that creates and manages WebRTC connections
*
* @param rtcConfig Optional RTCConfiguration for the peer connection
* @returns RondevuConnection instance
*/ */
private async findSessionById(id: string): Promise<{ createConnection(rtcConfig?: RTCConfiguration): RondevuConnection {
code: string; if (!this._offers) {
peerId: string; throw new Error('Not authenticated. Call register() first or provide credentials.');
offer: string;
topic?: string;
} | null> {
try {
// Try to poll for the session directly
// The poll endpoint should return the session data
const response = await this.client.poll(id, 'answerer');
const answererResponse = response as { offer: string; offerCandidates: string[] };
if (answererResponse.offer) {
return {
code: id,
peerId: '', // Will be populated from session data
offer: answererResponse.offer,
topic: undefined,
};
}
return null;
} catch (err) {
throw new Error(`Failed to find session ${id}: ${(err as Error).message}`);
} }
return new RondevuConnection(this._offers, rtcConfig);
} }
} }

View File

@@ -8,64 +8,7 @@
export type Side = 'offerer' | 'answerer'; export type Side = 'offerer' | 'answerer';
/** /**
* Session information returned from discovery endpoints * Request body for POST /offer
*/
export interface Session {
/** Unique session identifier (UUID) */
code: string;
/** Peer identifier/metadata */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
/** Unix timestamp when session was created */
createdAt: number;
/** Unix timestamp when session expires */
expiresAt: number;
}
/**
* Topic information with peer count
*/
export interface TopicInfo {
/** Topic identifier */
topic: string;
/** Number of available peers in this topic */
count: number;
}
/**
* Pagination information
*/
export interface Pagination {
/** Current page number */
page: number;
/** Results per page */
limit: number;
/** Total number of results */
total: number;
/** Whether there are more results available */
hasMore: boolean;
}
/**
* Response from GET / - list all topics
*/
export interface ListTopicsResponse {
topics: TopicInfo[];
pagination: Pagination;
}
/**
* Response from GET /:topic/sessions - list sessions in a topic
*/
export interface ListSessionsResponse {
sessions: Session[];
}
/**
* Request body for POST /:topic/offer
*/ */
export interface CreateOfferRequest { export interface CreateOfferRequest {
/** Peer identifier/metadata (max 1024 characters) */ /** Peer identifier/metadata (max 1024 characters) */
@@ -77,7 +20,7 @@ export interface CreateOfferRequest {
} }
/** /**
* Response from POST /:topic/offer * Response from POST /offer
*/ */
export interface CreateOfferResponse { export interface CreateOfferResponse {
/** Unique session identifier (UUID) */ /** Unique session identifier (UUID) */
@@ -140,12 +83,21 @@ export interface PollAnswererResponse {
*/ */
export type PollResponse = PollOffererResponse | PollAnswererResponse; export type PollResponse = PollOffererResponse | PollAnswererResponse;
/**
* Response from GET / - server version information
*/
export interface VersionResponse {
/** Git commit hash or version identifier */
version: string;
}
/** /**
* Response from GET /health * Response from GET /health
*/ */
export interface HealthResponse { export interface HealthResponse {
status: 'ok'; status: 'ok';
timestamp: number; timestamp: number;
version: string;
} }
/** /**
@@ -161,8 +113,6 @@ export interface ErrorResponse {
export interface RondevuClientOptions { export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */ /** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string; baseUrl: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */ /** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch; fetch?: typeof fetch;
} }
@@ -171,16 +121,23 @@ export interface RondevuClientOptions {
// WebRTC Types // WebRTC Types
// ============================================================================ // ============================================================================
/**
* WebRTC polyfill for Node.js and other non-browser platforms
*/
export interface WebRTCPolyfill {
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
}
/** /**
* Configuration options for Rondevu WebRTC client * Configuration options for Rondevu WebRTC client
*/ */
export interface RondevuOptions { export interface RondevuOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */ /** Base URL of the Rondevu server (defaults to 'https://api.ronde.vu') */
baseUrl: string; baseUrl?: string;
/** Peer identifier (optional, auto-generated if not provided) */ /** Peer identifier (optional, auto-generated if not provided) */
peerId?: string; peerId?: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */ /** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch; fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */ /** WebRTC configuration (ICE servers, etc.) */
@@ -189,16 +146,8 @@ export interface RondevuOptions {
pollingInterval?: number; pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */ /** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number; connectionTimeout?: number;
} /** WebRTC polyfill for Node.js (e.g., wrtc or @roamhq/wrtc) */
wrtc?: WebRTCPolyfill;
/**
* Options for joining a topic
*/
export interface JoinOptions {
/** Filter function to select specific sessions */
filter?: (session: { code: string; peerId: string }) => boolean;
/** Selection strategy for choosing a session */
select?: 'first' | 'newest' | 'oldest' | 'random';
} }
/** /**
@@ -211,13 +160,14 @@ export type ConnectionRole = 'offerer' | 'answerer';
*/ */
export interface RondevuConnectionParams { export interface RondevuConnectionParams {
id: string; id: string;
topic: string; topic?: string;
role: ConnectionRole; role: ConnectionRole;
pc: RTCPeerConnection; pc: RTCPeerConnection;
localPeerId: string; localPeerId: string;
remotePeerId: string; remotePeerId: string;
pollingInterval: number; pollingInterval: number;
connectionTimeout: number; connectionTimeout: number;
wrtc?: WebRTCPolyfill;
} }
/** /**