34 Commits

Author SHA1 Message Date
6c344ec8e1 0.7.0 2025-11-16 17:28:25 +01:00
5a5da124a6 Refactor peer connection state machine into separate files
Split the monolithic peer.ts file into a modular state-based architecture:
- Created separate files for each state class (idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed)
- Extracted shared types into types.ts
- Extracted base PeerState class into state.ts
- Updated peer/index.ts to import state classes instead of defining them inline
- Made close() method async to support dynamic imports and avoid circular dependencies
- Used dynamic imports in state transitions to prevent circular dependency issues

This improves code organization, maintainability, and makes each state's logic easier to understand and test.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:28:12 +01:00
c8b7a2913f feat: Implement proper trickle ICE support
Major improvements to connection establishment:

**Trickle ICE Implementation:**
- Send offer/answer to server IMMEDIATELY after creating SDP
- Don't wait for ICE gathering before sending offer/answer
- ICE candidates are now sent as they're discovered (true trickle ICE)
- Connection attempts can start with first candidates while more gather

**Removed Delays:**
- CreatingOfferState: No longer waits 10-15s for ICE before sending offer
- AnsweringState: No longer waits 10-15s for ICE before sending answer
- Answering state now takes ~50-200ms instead of 15+ seconds

**Code Organization:**
- Moved peer.ts to peer/index.ts directory structure
- Removed unused pendingCandidates buffering
- Removed unused waitForIceGathering methods
- Cleaned up timeout handling

**Breaking Changes:**
- "answering" state now transitions much faster to "exchanging-ice"
- ICE candidates start trickling immediately instead of in batches

This dramatically improves connection speed and follows WebRTC best practices.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:12:18 +01:00
6ddf7cb7f0 fix: Clear answer creation timeout before ICE gathering
The timeout for creating an answer was incorrectly including the
ICE gathering process, causing the answerer to fail when ICE gathering
took close to the timeout duration.

Now the timeout is cleared immediately after createAnswer() completes,
and ICE gathering relies on its own separate timeout.

Fixes connection failures where answerer would timeout even though
the answer was created successfully.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:47:58 +01:00
35ce051a26 chore: Bump version to 0.5.0
Breaking changes:
- Removed RondevuConnection (replaced by RondevuPeer)
- EventEmitter now uses protected emit()
- Content-based offer IDs (SHA-256 hash)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:40:25 +01:00
280c8c284f feat: Replace RondevuConnection with RondevuPeer state machine
- Created type-safe EventEmitter with generics
- Implemented state pattern for peer connection lifecycle
- Added comprehensive timeout handling for all connection phases
- Removed client-provided offer IDs (server generates hash-based IDs)
- Replaced RondevuConnection with RondevuPeer throughout
- Added states: idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed
- Configurable timeouts: ICE gathering, waiting for answer, creating answer, ICE connection
- Better error handling with 'failed' event and error details

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:33:44 +01:00
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
23 changed files with 1743 additions and 792 deletions

463
README.md
View File

@@ -1,60 +1,453 @@
# Rondevu
🎯 Meet WebRTC peers by topic, by peer ID, or by connection ID.
## @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 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
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
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
const conn = await rdv.join('room');
// Create a connection
const conn = client.createConnection();
// Or connect by ID
const conn = await rdv.connect('meeting-123');
// Set up event listeners
conn.on('connected', () => {
console.log('Connected to peer!');
});
// Use the connection
conn.on('connect', () => {
const channel = conn.dataChannel('chat');
channel.send('Hello!');
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
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:**
- `rdv.join(topic)` - Auto-connect to first peer in topic
- `rdv.join(topic, {filter})` - Connect to specific peer by ID
- `rdv.create(id, topic)` - Create connection for others to join
- `rdv.connect(id)` - Join connection by ID
```typescript
// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
**Connection Events:**
- `connect` - Connection established
- `disconnect` - Connection closed
- `datachannel` - Remote peer created data channel
- `stream` - Remote media stream received
- `error` - Error occurred
// Add tracks to connection
stream.getTracks().forEach(track => {
conn.addTrack(track, stream);
});
```
**Connection Methods:**
- `conn.dataChannel(label)` - Get or create data channel
- `conn.addStream(stream)` - Add media stream
- `conn.getPeerConnection()` - Get underlying RTCPeerConnection
- `conn.close()` - Close connection
### Connection Properties
### 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

39
package-lock.json generated Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",
"integrity": "sha512-110ejMCizPUPkHwwwNvcdCSZceLaHeFbf1LNkXvbG6pnLBqCf2uoGOOaRkArb7HNNFABFB+HXzm/AVzNdadosw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.0.4",
"description": "TypeScript client for Rondevu peer signaling and discovery server",
"version": "0.7.0",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -25,5 +25,8 @@
"files": [
"dist",
"README.md"
]
],
"dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1"
}
}

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 {
RondevuClientOptions,
ListTopicsResponse,
ListSessionsResponse,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
@@ -9,26 +7,25 @@ import {
PollRequest,
PollOffererResponse,
PollAnswererResponse,
VersionResponse,
HealthResponse,
ErrorResponse,
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 origin: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu client instance
* Creates a new Rondevu API client instance
* @param options - Client configuration options
*/
constructor(options: RondevuClientOptions) {
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);
}
@@ -42,7 +39,6 @@ export class RondevuClient {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Origin': this.origin,
...(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)
* @param limit - Results per page (max 1000)
* @returns List of topics with pagination info
* @returns Server version
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { topics, pagination } = await client.listTopics();
* console.log(`Found ${topics.length} topics`);
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { version } = await api.getVersion();
* console.log('Server version:', version);
* ```
*/
async listTopics(page = 1, limit = 100): Promise<ListTopicsResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
return this.request<ListTopicsResponse>(`/?${params}`, {
async getVersion(): Promise<VersionResponse> {
return this.request<VersionResponse>('/', {
method: 'GET',
});
}
/**
* Discovers available peers for a given topic
* Creates a new offer
*
* @param topic - Topic identifier
* @returns List of available sessions
* @param request - Offer details including peer ID, signaling data, and optional custom code
* @returns Unique offer code (UUID or custom code)
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { sessions } = await client.listSessions('my-room');
* 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', {
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { code } = await api.createOffer({
* peerId: 'peer-123',
* offer: signalingData
* offer: signalingData,
* code: 'my-custom-code' // optional
* });
* console.log('Session code:', code);
* console.log('Offer code:', code);
* ```
*/
async createOffer(
topic: string,
request: CreateOfferRequest
): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>(
`/${encodeURIComponent(topic)}/offer`,
{
async createOffer(request: CreateOfferRequest): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>('/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
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Send answer
* await client.sendAnswer({
* code: sessionCode,
* await api.sendAnswer({
* code: offerCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await client.sendAnswer({
* code: sessionCode,
* await api.sendAnswer({
* code: offerCode,
* candidate: candidateData,
* 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')
* @returns Session data including offers, answers, and candidates
* @returns Offer data including offers, answers, and candidates
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await client.poll(sessionCode, 'offerer');
* const offererData = await api.poll(offerCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await client.poll(sessionCode, 'answerer');
* const answererData = await api.poll(offerCode, 'answerer');
* 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
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const health = await client.health();
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const health = await api.health();
* console.log('Server status:', health.status);
* console.log('Server version:', health.version);
* ```
*/
async health(): Promise<HealthResponse> {
@@ -220,4 +186,23 @@ export class RondevuClient {
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 +0,0 @@
import { EventEmitter } from './event-emitter';
import { RondevuClient } from './client';
import { RondevuConnectionParams } from './types';
/**
* Represents a WebRTC connection with automatic signaling and ICE exchange
*/
export class RondevuConnection extends EventEmitter {
readonly id: string;
readonly topic: string;
readonly role: 'offerer' | 'answerer';
readonly remotePeerId: string;
private pc: RTCPeerConnection;
private client: RondevuClient;
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;
constructor(params: RondevuConnectionParams, client: RondevuClient) {
super();
this.id = params.id;
this.topic = params.topic;
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.setupEventHandlers();
this.startConnectionTimeout();
}
/**
* Setup RTCPeerConnection event handlers
*/
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}`));
});
}
};
// 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':
this.clearConnectionTimeout();
this.stopPolling();
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
*/
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}`);
}
}
/**
* Start polling for remote session data (answer/candidates)
*/
startPolling(): void {
if (this.isPolling || this.isClosed) {
return;
}
this.isPolling = true;
// Poll immediately
this.poll().catch((err) => {
this.emit('error', new Error(`Poll error: ${err.message}`));
});
// Set up interval polling
this.pollingInterval = setInterval(() => {
this.poll().catch((err) => {
this.emit('error', new Error(`Poll error: ${err.message}`));
});
}, this.pollingIntervalMs);
}
/**
* Stop polling
*/
private stopPolling(): void {
this.isPolling = false;
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = undefined;
}
}
/**
* Poll the signaling server for remote data
*/
private async poll(): Promise<void> {
if (this.isClosed) {
this.stopPolling();
return;
}
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) {
await this.pc.setRemoteDescription({
type: 'answer',
sdp: offererResponse.answer,
});
}
// 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 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 RTCIceCandidate(candidate));
} catch (err) {
console.warn('Failed to add ICE candidate:', 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;
}
}
/**
* Handle remotely created data channel
*/
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;
}
}
/**
* Close the connection and cleanup resources
*/
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');
}
}

View File

@@ -1,17 +1,37 @@
/**
* Simple EventEmitter implementation for browser and Node.js compatibility
* Type-safe EventEmitter implementation for browser and Node.js compatibility
*
* @template EventMap - A type mapping event names to their handler signatures
*
* @example
* ```typescript
* interface MyEvents {
* 'data': (value: string) => void;
* 'error': (error: Error) => void;
* 'ready': () => void;
* }
*
* class MyClass extends EventEmitter<MyEvents> {
* doSomething() {
* this.emit('data', 'hello'); // Type-safe!
* this.emit('error', new Error('oops')); // Type-safe!
* this.emit('ready'); // Type-safe!
* }
* }
*
* const instance = new MyClass();
* instance.on('data', (value) => {
* console.log(value.toUpperCase()); // 'value' is typed as string
* });
* ```
*/
export class EventEmitter {
private events: Map<string, Set<Function>>;
constructor() {
this.events = new Map();
}
export class EventEmitter<EventMap extends Record<string, (...args: any[]) => void>> {
private events: Map<keyof EventMap, Set<Function>> = new Map();
/**
* Register an event listener
*/
on(event: string, listener: Function): this {
on<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
@@ -22,18 +42,18 @@ export class EventEmitter {
/**
* Register a one-time event listener
*/
once(event: string, listener: Function): this {
const onceWrapper = (...args: any[]) => {
this.off(event, onceWrapper);
listener.apply(this, args);
once<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
const onceWrapper = (...args: Parameters<EventMap[K]>) => {
this.off(event, onceWrapper as EventMap[K]);
listener(...args);
};
return this.on(event, onceWrapper);
return this.on(event, onceWrapper as EventMap[K]);
}
/**
* Remove an event listener
*/
off(event: string, listener: Function): this {
off<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
const listeners = this.events.get(event);
if (listeners) {
listeners.delete(listener);
@@ -47,7 +67,10 @@ export class EventEmitter {
/**
* Emit an event
*/
emit(event: string, ...args: any[]): boolean {
protected emit<K extends keyof EventMap>(
event: K,
...args: Parameters<EventMap[K]>
): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.size === 0) {
return false;
@@ -55,9 +78,9 @@ export class EventEmitter {
listeners.forEach(listener => {
try {
listener.apply(this, args);
(listener as EventMap[K])(...args);
} catch (err) {
console.error(`Error in ${event} event listener:`, err);
console.error(`Error in ${String(event)} event listener:`, err);
}
});
@@ -67,8 +90,8 @@ export class EventEmitter {
/**
* Remove all listeners for an event (or all events if not specified)
*/
removeAllListeners(event?: string): this {
if (event) {
removeAllListeners<K extends keyof EventMap>(event?: K): this {
if (event !== undefined) {
this.events.delete(event);
} else {
this.events.clear();
@@ -79,7 +102,7 @@ export class EventEmitter {
/**
* Get listener count for an event
*/
listenerCount(event: string): number {
listenerCount<K extends keyof EventMap>(event: K): number {
const listeners = this.events.get(event);
return listeners ? listeners.size : 0;
}

View File

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

327
src/offers.ts Normal file
View File

@@ -0,0 +1,327 @@
import { Credentials, FetchFunction } from './auth.js';
import { RondevuAuth } from './auth.js';
// Declare Buffer for Node.js compatibility
declare const Buffer: any;
export interface CreateOfferRequest {
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: any; // Full candidate object as plain JSON - don't enforce structure
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: any[]
): 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

@@ -0,0 +1,56 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Answering an offer and sending to server
*/
export class AnsweringState extends PeerState {
constructor(peer: RondevuPeer) {
super(peer);
}
get name() { return 'answering'; }
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
try {
this.peer.role = 'answerer';
this.peer.offerId = offerId;
// Set remote description
await this.peer.pc.setRemoteDescription({
type: 'offer',
sdp: offerSdp
});
// Create answer
const answer = await this.peer.pc.createAnswer();
await this.peer.pc.setLocalDescription(answer);
// Send answer to server immediately (don't wait for ICE)
await this.peer.offersApi.answer(offerId, answer.sdp!);
// Enable trickle ICE - send candidates as they arrive
this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
// Transition to exchanging ICE
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
this.peer.setState(new ExchangingIceState(this.peer, offerId, options));
} catch (error) {
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(this.peer, error as Error));
throw error;
}
}
}

12
src/peer/closed-state.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PeerState } from './state.js';
/**
* Closed state - connection has been terminated
*/
export class ClosedState extends PeerState {
get name() { return 'closed'; }
cleanup(): void {
this.peer.pc.close();
}
}

View File

@@ -0,0 +1,13 @@
import { PeerState } from './state.js';
/**
* Connected state - peer connection is established
*/
export class ConnectedState extends PeerState {
get name() { return 'connected'; }
cleanup(): void {
// Keep connection alive, but stop any polling
// The peer connection will handle disconnects via onconnectionstatechange
}
}

View File

@@ -0,0 +1,66 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Creating offer and sending to server
*/
export class CreatingOfferState extends PeerState {
constructor(peer: RondevuPeer, private options: PeerOptions) {
super(peer);
}
get name() { return 'creating-offer'; }
async createOffer(options: PeerOptions): Promise<string> {
try {
this.peer.role = 'offerer';
// Create data channel if requested
if (options.createDataChannel !== false) {
const channel = this.peer.pc.createDataChannel(
options.dataChannelLabel || 'data'
);
this.peer.emitEvent('datachannel', channel);
}
// Create WebRTC offer
const offer = await this.peer.pc.createOffer();
await this.peer.pc.setLocalDescription(offer);
// Send offer to server immediately (don't wait for ICE)
const offers = await this.peer.offersApi.create([{
sdp: offer.sdp!,
topics: options.topics,
ttl: options.ttl || 300000
}]);
const offerId = offers[0].id;
this.peer.offerId = offerId;
// Enable trickle ICE - send candidates as they arrive
this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
// Transition to waiting for answer
const { WaitingForAnswerState } = await import('./waiting-for-answer-state.js');
this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options));
return offerId;
} catch (error) {
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(this.peer, error as Error));
throw error;
}
}
}

View File

@@ -0,0 +1,74 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Exchanging ICE candidates and waiting for connection
*/
export class ExchangingIceState extends PeerState {
private pollingInterval?: ReturnType<typeof setInterval>;
private timeout?: ReturnType<typeof setTimeout>;
private lastIceTimestamp = 0;
constructor(
peer: RondevuPeer,
private offerId: string,
private options: PeerOptions
) {
super(peer);
this.startPolling();
}
get name() { return 'exchanging-ice'; }
private startPolling(): void {
const connectionTimeout = this.options.timeouts?.iceConnection || 30000;
this.timeout = setTimeout(async () => {
this.cleanup();
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(
this.peer,
new Error('ICE connection timeout')
));
}, connectionTimeout);
this.pollingInterval = setInterval(async () => {
try {
const candidates = await this.peer.offersApi.getIceCandidates(
this.offerId,
this.lastIceTimestamp
);
for (const cand of candidates) {
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
try {
await this.peer.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
this.lastIceTimestamp = cand.createdAt;
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
this.lastIceTimestamp = cand.createdAt;
}
} else {
this.lastIceTimestamp = cand.createdAt;
}
}
} catch (err) {
console.error('Error polling for ICE candidates:', err);
if (err instanceof Error && err.message.includes('not found')) {
this.cleanup();
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(
this.peer,
new Error('Offer expired or not found')
));
}
}
}, 1000);
}
cleanup(): void {
if (this.pollingInterval) clearInterval(this.pollingInterval);
if (this.timeout) clearTimeout(this.timeout);
}
}

18
src/peer/failed-state.ts Normal file
View File

@@ -0,0 +1,18 @@
import { PeerState } from './state.js';
/**
* Failed state - connection attempt failed
*/
export class FailedState extends PeerState {
constructor(peer: any, private error: Error) {
super(peer);
peer.emitEvent('failed', error);
}
get name() { return 'failed'; }
cleanup(): void {
// Connection is failed, clean up resources
this.peer.pc.close();
}
}

18
src/peer/idle-state.ts Normal file
View File

@@ -0,0 +1,18 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
export class IdleState extends PeerState {
get name() { return 'idle'; }
async createOffer(options: PeerOptions): Promise<string> {
const { CreatingOfferState } = await import('./creating-offer-state.js');
this.peer.setState(new CreatingOfferState(this.peer, options));
return this.peer.state.createOffer(options);
}
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
const { AnsweringState } = await import('./answering-state.js');
this.peer.setState(new AnsweringState(this.peer));
return this.peer.state.answer(offerId, offerSdp, options);
}
}

151
src/peer/index.ts Normal file
View File

@@ -0,0 +1,151 @@
import { RondevuOffers } from '../offers.js';
import { EventEmitter } from '../event-emitter.js';
import type { PeerOptions, PeerEvents } from './types.js';
import { PeerState } from './state.js';
import { IdleState } from './idle-state.js';
import { CreatingOfferState } from './creating-offer-state.js';
import { WaitingForAnswerState } from './waiting-for-answer-state.js';
import { AnsweringState } from './answering-state.js';
import { ExchangingIceState } from './exchanging-ice-state.js';
import { ConnectedState } from './connected-state.js';
import { FailedState } from './failed-state.js';
import { ClosedState } from './closed-state.js';
// Re-export types for external consumers
export type { PeerTimeouts, PeerOptions, PeerEvents } from './types.js';
/**
* High-level WebRTC peer connection manager with state-based lifecycle
* Handles offer/answer exchange, ICE candidates, timeouts, and error recovery
*/
export default class RondevuPeer extends EventEmitter<PeerEvents> {
pc: RTCPeerConnection;
offersApi: RondevuOffers;
offerId?: string;
role?: 'offerer' | 'answerer';
private _state: PeerState;
/**
* Current connection state name
*/
get stateName(): string {
return this._state.name;
}
/**
* Current state object (internal use)
*/
get state(): PeerState {
return this._state;
}
/**
* RTCPeerConnection state
*/
get connectionState(): RTCPeerConnectionState {
return this.pc.connectionState;
}
constructor(
offersApi: RondevuOffers,
rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
) {
super();
this.offersApi = offersApi;
this.pc = new RTCPeerConnection(rtcConfig);
this._state = new IdleState(this);
this.setupPeerConnection();
}
/**
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.pc.onconnectionstatechange = () => {
switch (this.pc.connectionState) {
case 'connected':
this.setState(new ConnectedState(this));
this.emitEvent('connected');
break;
case 'disconnected':
this.emitEvent('disconnected');
break;
case 'failed':
this.setState(new FailedState(this, new Error('Connection failed')));
break;
case 'closed':
this.setState(new ClosedState(this));
this.emitEvent('disconnected');
break;
}
};
this.pc.ondatachannel = (event) => {
this.emitEvent('datachannel', event.channel);
};
this.pc.ontrack = (event) => {
this.emitEvent('track', event);
};
this.pc.onicecandidateerror = (event) => {
console.error('ICE candidate error:', event);
};
}
/**
* Set new state and emit state change event
*/
setState(newState: PeerState): void {
this._state.cleanup();
this._state = newState;
this.emitEvent('state', newState.name);
}
/**
* Emit event (exposed for PeerState classes)
* @internal
*/
emitEvent<K extends keyof PeerEvents>(
event: K,
...args: Parameters<PeerEvents[K]>
): void {
this.emit(event, ...args);
}
/**
* Create an offer and advertise on topics
*/
async createOffer(options: PeerOptions): Promise<string> {
return this._state.createOffer(options);
}
/**
* Answer an existing offer
*/
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
return this._state.answer(offerId, offerSdp, options);
}
/**
* 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
*/
async close(): Promise<void> {
await this._state.close();
this.removeAllListeners();
}
}

41
src/peer/state.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Base class for peer connection states
* Implements the State pattern for managing WebRTC connection lifecycle
*/
export abstract class PeerState {
constructor(protected peer: RondevuPeer) {}
abstract get name(): string;
async createOffer(options: PeerOptions): Promise<string> {
throw new Error(`Cannot create offer in ${this.name} state`);
}
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
throw new Error(`Cannot answer in ${this.name} state`);
}
async handleAnswer(sdp: string): Promise<void> {
throw new Error(`Cannot handle answer in ${this.name} state`);
}
async handleIceCandidate(candidate: any): Promise<void> {
// ICE candidates can arrive in multiple states, so default is to add them
if (this.peer.pc.remoteDescription) {
await this.peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
}
cleanup(): void {
// Override in states that need cleanup
}
async close(): Promise<void> {
this.cleanup();
const { ClosedState } = await import('./closed-state.js');
this.peer.setState(new ClosedState(this.peer));
}
}

43
src/peer/types.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Timeout configurations for different connection phases
*/
export interface PeerTimeouts {
/** Timeout for ICE gathering (default: 10000ms) */
iceGathering?: number;
/** Timeout for waiting for answer (default: 30000ms) */
waitingForAnswer?: number;
/** Timeout for creating answer (default: 10000ms) */
creatingAnswer?: number;
/** Timeout for ICE connection (default: 30000ms) */
iceConnection?: number;
}
/**
* Options for creating a peer connection
*/
export interface PeerOptions {
/** RTCConfiguration for the peer connection */
rtcConfig?: RTCConfiguration;
/** Topics to advertise this connection under */
topics: string[];
/** How long the offer should live (milliseconds) */
ttl?: number;
/** Whether to create a data channel automatically (for offerer) */
createDataChannel?: boolean;
/** Label for the automatically created data channel */
dataChannelLabel?: string;
/** Timeout configurations */
timeouts?: PeerTimeouts;
}
/**
* Events emitted by RondevuPeer
*/
export interface PeerEvents extends Record<string, (...args: any[]) => void> {
'state': (state: string) => void;
'connected': () => void;
'disconnected': () => void;
'failed': (error: Error) => void;
'datachannel': (channel: RTCDataChannel) => void;
'track': (event: RTCTrackEvent) => void;
}

View File

@@ -0,0 +1,78 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Waiting for answer from another peer
*/
export class WaitingForAnswerState extends PeerState {
private pollingInterval?: ReturnType<typeof setInterval>;
private timeout?: ReturnType<typeof setTimeout>;
constructor(
peer: RondevuPeer,
private offerId: string,
private options: PeerOptions
) {
super(peer);
this.startPolling();
}
get name() { return 'waiting-for-answer'; }
private startPolling(): void {
const answerTimeout = this.options.timeouts?.waitingForAnswer || 30000;
this.timeout = setTimeout(async () => {
this.cleanup();
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(
this.peer,
new Error('Timeout waiting for answer')
));
}, answerTimeout);
this.pollingInterval = setInterval(async () => {
try {
const answers = await this.peer.offersApi.getAnswers();
const myAnswer = answers.find((a: any) => a.offerId === this.offerId);
if (myAnswer) {
this.cleanup();
await this.handleAnswer(myAnswer.sdp);
}
} catch (err) {
console.error('Error polling for answers:', err);
if (err instanceof Error && err.message.includes('not found')) {
this.cleanup();
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(
this.peer,
new Error('Offer expired or not found')
));
}
}
}, 2000);
}
async handleAnswer(sdp: string): Promise<void> {
try {
await this.peer.pc.setRemoteDescription({
type: 'answer',
sdp
});
// Transition to exchanging ICE
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
this.peer.setState(new ExchangingIceState(this.peer, this.offerId, this.options));
} catch (error) {
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(this.peer, error as Error));
}
}
cleanup(): void {
if (this.pollingInterval) clearInterval(this.pollingInterval);
if (this.timeout) clearTimeout(this.timeout);
}
}

View File

@@ -1,266 +1,103 @@
import { RondevuClient } from './client';
import { RondevuConnection } from './connection';
import { RondevuOptions, JoinOptions, RondevuConnectionParams } from './types';
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js';
import RondevuPeer from './peer/index.js';
/**
* Main Rondevu WebRTC client with automatic connection management
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;
}
export class Rondevu {
readonly peerId: string;
readonly auth: RondevuAuth;
private _offers?: RondevuOffers;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
private client: RondevuClient;
private rtcConfig?: RTCConfiguration;
private pollingInterval: number;
private connectionTimeout: number;
constructor(options: RondevuOptions = {}) {
this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
this.fetchFn = options.fetch;
/**
* 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,
});
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;
if (options.credentials) {
this.credentials = options.credentials;
this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
}
}
/**
* Generate a unique peer ID
* Get offers API (requires authentication)
*/
private generatePeerId(): string {
return `rdv_${Math.random().toString(36).substring(2, 14)}`;
get offers(): RondevuOffers {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._offers;
}
/**
* Update the peer ID (useful when user identity changes)
* Register and initialize authenticated client
*/
updatePeerId(newPeerId: string): void {
(this as any).peerId = newPeerId;
}
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
/**
* 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)
* @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> {
// List sessions in topic
const { sessions } = await this.client.listSessions(topic);
// Filter out self (sessions with our peer ID)
let availableSessions = sessions.filter(
session => session.peerId !== this.peerId
// Create offers API instance
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
);
// Apply custom filter if provided
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);
return this.credentials;
}
/**
* Select a session based on strategy
* Check if client is authenticated
*/
private selectSession(
sessions: Array<{ code: string; peerId: string; createdAt: number }>,
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];
}
isAuthenticated(): boolean {
return !!this.credentials;
}
/**
* Wait for ICE gathering to complete
* Get current credentials
*/
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);
});
getCredentials(): Credentials | undefined {
return this.credentials;
}
/**
* Find a session by connection ID
* This requires polling since we don't know which topic it's in
* Create a new WebRTC peer connection (requires authentication)
* This is a high-level helper that creates and manages WebRTC connections with state management
*
* @param rtcConfig Optional RTCConfiguration for the peer connection
* @returns RondevuPeer instance
*/
private async findSessionById(id: string): Promise<{
code: string;
peerId: string;
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,
};
createPeer(rtcConfig?: RTCConfiguration): RondevuPeer {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return null;
} catch (err) {
throw new Error(`Failed to find session ${id}: ${(err as Error).message}`);
}
return new RondevuPeer(this._offers, rtcConfig);
}
}

View File

@@ -8,64 +8,7 @@
export type Side = 'offerer' | 'answerer';
/**
* Session information returned from discovery endpoints
*/
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
* Request body for POST /offer
*/
export interface CreateOfferRequest {
/** 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 {
/** Unique session identifier (UUID) */
@@ -140,12 +83,21 @@ export interface 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
*/
export interface HealthResponse {
status: 'ok';
timestamp: number;
version: string;
}
/**
@@ -161,8 +113,6 @@ export interface ErrorResponse {
export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
@@ -171,16 +121,23 @@ export interface RondevuClientOptions {
// 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
*/
export interface RondevuOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Base URL of the Rondevu server (defaults to 'https://api.ronde.vu') */
baseUrl?: string;
/** Peer identifier (optional, auto-generated if not provided) */
peerId?: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */
@@ -189,16 +146,8 @@ export interface RondevuOptions {
pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number;
}
/**
* 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';
/** WebRTC polyfill for Node.js (e.g., wrtc or @roamhq/wrtc) */
wrtc?: WebRTCPolyfill;
}
/**
@@ -211,13 +160,14 @@ export type ConnectionRole = 'offerer' | 'answerer';
*/
export interface RondevuConnectionParams {
id: string;
topic: string;
topic?: string;
role: ConnectionRole;
pc: RTCPeerConnection;
localPeerId: string;
remotePeerId: string;
pollingInterval: number;
connectionTimeout: number;
wrtc?: WebRTCPolyfill;
}
/**