31 Commits

Author SHA1 Message Date
6057c3c582 0.7.11 2025-11-22 17:34:11 +01:00
255fe42a43 Add optional info field to offers
- Add info field to CreateOfferRequest and Offer types
- Update README with info field examples and documentation
- Public metadata field visible in all API responses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:34:11 +01:00
83fd0f84a4 0.7.10 2025-11-22 16:10:28 +01:00
aa53d5bc3d Add custom peer ID support to register method
- Update register() to accept optional customPeerId parameter
- Add TypeScript documentation with JSDoc comments
- Update README with usage examples and documentation
- Maintain backward compatibility with auto-generated IDs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 16:10:24 +01:00
f5aa6e2189 0.7.9 2025-11-17 22:32:09 +01:00
afdca83640 Add createDataChannel method to RondevuPeer
Adds a public method to create RTCDataChannels for sending/receiving arbitrary data between peers. The offerer can call this method before creating an offer, and the answerer will receive the channel via the existing 'datachannel' event.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 22:31:58 +01:00
c7ea1b9b8f 0.7.8 2025-11-17 22:08:54 +01:00
660663945e Update README to remove scoped package name from title 2025-11-17 21:45:03 +01:00
f119a42fcd Update README to include live API link for rondevu-server 2025-11-17 21:44:13 +01:00
cd55072acb Update live demo link in README to use ronde.vu domain 2025-11-17 21:43:09 +01:00
26f71e7a2b Expand README with links to related repositories and NPM packages 2025-11-17 21:41:45 +01:00
0ac1f94502 Integrate secret parameter into peer classes
- Add secret field to PeerOptions interface
- Pass secret when creating offers in CreatingOfferState
- Pass secret when answering offers in AnsweringState
- Bump version to 0.7.7

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:13:19 +01:00
3530213870 Update README with secret field documentation
- Document secret parameter in offer creation examples
- Add Protected Offers section with detailed usage
- Update API reference for create() and answer() methods
- Show hasSecret flag in discovery responses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:03:49 +01:00
e052464482 Add startsWith parameter to getTopics method
Added optional startsWith parameter to topics query:
- Filters topics by prefix on the server side
- Updated TypeScript types
- Supports response with startsWith field

Version bumped to 0.7.5

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:42:00 +01:00
53206d306b Add WebRTC polyfill support for Node.js environments
Added optional polyfill parameters to RondevuOptions to support Node.js:
- RTCPeerConnection: Custom peer connection implementation
- RTCSessionDescription: Custom session description implementation
- RTCIceCandidate: Custom ICE candidate implementation

This allows users to plug in wrtc or node-webrtc packages for full
WebRTC support in Node.js environments. Updated documentation with
usage examples and environment compatibility matrix.

Version bumped to 0.7.4

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:16:42 +01:00
c860419e66 Remove unused code (legacy files and heartbeat method)
- Removed unused legacy files: client.ts and types.ts (old API)
- Removed heartbeat() method from offers API (doesn't actually reset TTL)
- Removed heartbeat() documentation from README
- Server only uses expires_at for cleanup, last_seen is never checked
- Offers expire purely based on their original TTL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:32:21 +01:00
e22e74fb74 Update README to use client.createPeer() method
- Replaced `new RondevuPeer(client.offers)` with `client.createPeer()`
- Updated import to only import Rondevu (not RondevuPeer)
- Updated Custom RTCConfiguration example to pass config to createPeer()
- Removed rtcConfig from answer() call (should be passed to createPeer)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:03:39 +01:00
135eda73cf Update README to reflect current RondevuPeer API
- Replaced all references to removed RondevuConnection class
- Updated to use RondevuPeer with state machine lifecycle
- Documented state transitions (idle → creating-offer → waiting-for-answer → exchanging-ice → connected)
- Added trickle ICE documentation
- Updated all code examples to use addEventListener
- Added timeout configuration examples
- Documented peer properties (stateName, connectionState, offerId, role)
- Updated TypeScript types in API reference

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:00:15 +01:00
8d7075ccc4 0.7.3 2025-11-16 17:51:24 +01:00
db8f0f4ced Fix answerer authorization for ICE candidates
The answerer was getting 403 Forbidden when sending ICE candidates because
the server didn't know who the answerer was yet. ICE gathering starts when
setLocalDescription is called, but we were calling /answer AFTER that.

Fixed by sending the answer to the server BEFORE setLocalDescription:
1. Create answer SDP
2. Send answer to server (registers answererPeerId)
3. Set up ICE handler
4. Set local description (ICE gathering starts)

This ensures the server has answererPeerId set before ICE candidates arrive,
so they're properly authorized.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:51:04 +01:00
3a227a21ac 0.7.2 2025-11-16 17:45:02 +01:00
de1f3eac9c Fix critical ICE candidate timing bug
ICE candidate handler was being set up AFTER setLocalDescription, but ICE
gathering starts when setLocalDescription is called. This meant candidates
were generated before the handler was attached, so they were never sent to
the server, causing connection failures.

Fixed by:
- Setting up ICE handler BEFORE setLocalDescription in both offer and answer flows
- Changed setupIceCandidateHandler() to use this.peer.offerId instead of parameter
- Handler now checks this.peer.offerId before sending (waits for it to be set)

Order of operations now:
1. Set up ICE candidate handler
2. Call setLocalDescription (ICE gathering starts)
3. Set this.peer.offerId (handler can now send candidates)

This ensures all ICE candidates are captured and sent to the server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:44:55 +01:00
557cc0a838 0.7.1 2025-11-16 17:35:47 +01:00
6e661f69bc Extract duplicate ICE candidate handler code to base PeerState class
Refactored common ICE candidate handling logic to reduce code duplication:
- Added setupIceCandidateHandler() method to base PeerState class
- Moved iceCandidateHandler property to base class
- Updated cleanup() in base class to remove ICE candidate handler
- Removed duplicate handler code from CreatingOfferState and AnsweringState
- Both states now call this.setupIceCandidateHandler(offerId)

This eliminates ~15 lines of duplicated code per state and ensures consistent ICE candidate handling across all states that need it.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:35:40 +01:00
00f4da7250 Replace .on* event handlers with addEventListener/removeEventListener
Updated all event handler assignments to use addEventListener instead of .on* properties:
- peer/index.ts: Replaced onconnectionstatechange, ondatachannel, ontrack, onicecandidateerror
- creating-offer-state.ts: Replaced onicecandidate
- answering-state.ts: Replaced onicecandidate

Benefits:
- Proper cleanup with removeEventListener
- Prevents memory leaks by removing listeners when states/peer close
- Allows multiple listeners for the same event
- More modern and explicit event handling approach

All event handlers are now stored as class properties and properly cleaned up in cleanup()/close() methods.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:33:32 +01:00
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
22 changed files with 1083 additions and 929 deletions

345
README.md
View File

@@ -1,14 +1,15 @@
# @xtr-dev/rondevu-client # 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)
🌐 **Topic-based peer discovery and WebRTC signaling client** 🌐 **Topic-based peer discovery and WebRTC signaling client**
TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling. TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling with trickle ICE support.
**Related repositories:** **Related repositories:**
- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo - [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
--- ---
@@ -16,9 +17,12 @@ TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery,
- **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes) - **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes)
- **Stateless Authentication**: No server-side sessions, portable credentials - **Stateless Authentication**: No server-side sessions, portable credentials
- **Protected Connections**: Optional secret-protected offers for access control
- **Bloom Filters**: Efficient peer exclusion for repeated discoveries - **Bloom Filters**: Efficient peer exclusion for repeated discoveries
- **Multi-Offer Management**: Create and manage multiple offers per peer - **Multi-Offer Management**: Create and manage multiple offers per peer
- **Complete WebRTC Signaling**: Full offer/answer and ICE candidate exchange - **Complete WebRTC Signaling**: Full offer/answer and ICE candidate exchange
- **Trickle ICE**: Send ICE candidates as they're discovered (faster connections)
- **State Machine**: Clean state-based connection lifecycle
- **TypeScript**: Full type safety and autocomplete - **TypeScript**: Full type safety and autocomplete
## Install ## Install
@@ -29,38 +33,45 @@ npm install @xtr-dev/rondevu-client
## Quick Start ## 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) ### Creating an Offer (Peer A)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register(); await client.register();
// Create a connection // Create peer connection
const conn = client.createConnection(); const peer = client.createPeer();
// Set up event listeners // Set up event listeners
conn.on('connected', () => { peer.on('state', (state) => {
console.log('Connected to peer!'); console.log('Peer state:', state);
// States: idle → creating-offer → waiting-for-answer → exchanging-ice → connected
}); });
conn.on('datachannel', (channel) => { peer.on('connected', () => {
console.log('Data channel ready'); console.log('✅ Connected to peer!');
});
channel.onmessage = (event) => { peer.on('datachannel', (channel) => {
console.log('Received:', event.data); console.log('📡 Data channel ready');
};
channel.addEventListener('message', (event) => {
console.log('📥 Received:', event.data);
});
channel.addEventListener('open', () => {
channel.send('Hello from peer A!'); channel.send('Hello from peer A!');
}); });
});
// Create offer and advertise on topics // Create offer and advertise on topics
const offerId = await conn.createOffer({ const offerId = await peer.createOffer({
topics: ['my-app', 'room-123'], topics: ['my-app', 'room-123'],
ttl: 300000 // 5 minutes ttl: 300000, // 5 minutes
secret: 'my-secret-password' // Optional: protect offer (max 128 chars)
}); });
console.log('Offer created:', offerId); console.log('Offer created:', offerId);
@@ -72,6 +83,7 @@ console.log('Share these topics with peers:', ['my-app', 'room-123']);
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register(); await client.register();
@@ -81,65 +93,143 @@ const offers = await client.offers.findByTopic('my-app', { limit: 10 });
if (offers.length > 0) { if (offers.length > 0) {
const offer = offers[0]; const offer = offers[0];
// Create connection // Create peer connection
const conn = client.createConnection(); const peer = client.createPeer();
// Set up event listeners // Set up event listeners
conn.on('connecting', () => { peer.on('state', (state) => {
console.log('Connecting...'); console.log('Peer state:', state);
// States: idle → answering → exchanging-ice → connected
}); });
conn.on('connected', () => { peer.on('connected', () => {
console.log('Connected!'); console.log('Connected!');
}); });
conn.on('datachannel', (channel) => { peer.on('datachannel', (channel) => {
console.log('Data channel ready'); console.log('📡 Data channel ready');
channel.onmessage = (event) => { channel.addEventListener('message', (event) => {
console.log('Received:', event.data); console.log('📥 Received:', event.data);
}; });
channel.addEventListener('open', () => {
channel.send('Hello from peer B!'); channel.send('Hello from peer B!');
}); });
});
peer.on('failed', (error) => {
console.error('❌ Connection failed:', error);
});
// Answer the offer // Answer the offer
await conn.answer(offer.id, offer.sdp); await peer.answer(offer.id, offer.sdp, {
topics: offer.topics,
secret: 'my-secret-password' // Required if offer.hasSecret is true
});
} }
``` ```
### Connection Events ## Protected Offers
You can protect offers with a secret to control who can answer them. This is useful for private rooms or invite-only connections.
### Creating a Protected Offer
```typescript ```typescript
conn.on('connecting', () => { const offerId = await peer.createOffer({
// Connection is being established topics: ['private-room'],
secret: 'my-secret-password' // Max 128 characters
}); });
conn.on('connected', () => { // Share the secret with authorized peers through a secure channel
```
### Answering a Protected Offer
```typescript
const offers = await client.offers.findByTopic('private-room');
// Check if offer requires a secret
if (offers[0].hasSecret) {
console.log('This offer requires a secret');
}
// Provide the secret when answering
await peer.answer(offers[0].id, offers[0].sdp, {
topics: offers[0].topics,
secret: 'my-secret-password' // Must match the offer's secret
});
```
**Notes:**
- The actual secret is never exposed in public API responses - only a `hasSecret` boolean flag
- Answerers must provide the correct secret, or the answer will be rejected
- Secrets are limited to 128 characters
- Use this for access control, not for cryptographic security (use end-to-end encryption for that)
## Connection Lifecycle
The `RondevuPeer` uses a state machine for connection management:
### Offerer States
1. **idle** - Initial state
2. **creating-offer** - Creating WebRTC offer
3. **waiting-for-answer** - Polling for answer from peer
4. **exchanging-ice** - Exchanging ICE candidates
5. **connected** - Successfully connected
6. **failed** - Connection failed
7. **closed** - Connection closed
### Answerer States
1. **idle** - Initial state
2. **answering** - Creating WebRTC answer
3. **exchanging-ice** - Exchanging ICE candidates
4. **connected** - Successfully connected
5. **failed** - Connection failed
6. **closed** - Connection closed
### State Events
```typescript
peer.on('state', (stateName) => {
console.log('Current state:', stateName);
});
peer.on('connected', () => {
// Connection established successfully // Connection established successfully
}); });
conn.on('disconnected', () => { peer.on('disconnected', () => {
// Connection lost or closed // Connection lost or closed
}); });
conn.on('error', (error) => { peer.on('failed', (error) => {
// An error occurred // Connection failed
console.error('Connection error:', error); console.error('Connection error:', error);
}); });
conn.on('datachannel', (channel) => { peer.on('datachannel', (channel) => {
// Data channel is ready to use // Data channel is ready (use channel.addEventListener)
}); });
conn.on('track', (event) => { peer.on('track', (event) => {
// Media track received (for audio/video streaming) // Media track received (for audio/video streaming)
const stream = event.streams[0]; const stream = event.streams[0];
videoElement.srcObject = stream; videoElement.srcObject = stream;
}); });
``` ```
### Adding Media Tracks ## Trickle ICE
This library implements **trickle ICE** for faster connection establishment:
- ICE candidates are sent to the server as they're discovered
- No waiting for all candidates before sending offer/answer
- Connections establish much faster (milliseconds vs seconds)
- Proper event listener cleanup to prevent memory leaks
## Adding Media Tracks
```typescript ```typescript
// Get user's camera/microphone // Get user's camera/microphone
@@ -148,29 +238,64 @@ const stream = await navigator.mediaDevices.getUserMedia({
audio: true audio: true
}); });
// Add tracks to connection // Add tracks to peer connection
stream.getTracks().forEach(track => { stream.getTracks().forEach(track => {
conn.addTrack(track, stream); peer.addTrack(track, stream);
}); });
``` ```
### Connection Properties ## Peer Properties
```typescript ```typescript
// Get current state name
console.log(peer.stateName); // 'idle', 'creating-offer', 'connected', etc.
// Get connection state // Get connection state
console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc. console.log(peer.connectionState); // RTCPeerConnectionState
// Get offer ID // Get offer ID (after creating offer or answering)
console.log(conn.id); console.log(peer.offerId);
// Get data channel // Get role
console.log(conn.channel); console.log(peer.role); // 'offerer' or 'answerer'
``` ```
### Closing a Connection ## Closing a Connection
```typescript ```typescript
conn.close(); await peer.close();
```
## Custom RTCConfiguration
```typescript
const peer = client.createPeer({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
],
iceTransportPolicy: 'relay' // Force TURN relay (useful for testing)
});
```
## Timeouts
Configure connection timeouts:
```typescript
await peer.createOffer({
topics: ['my-topic'],
timeouts: {
iceGathering: 10000, // ICE gathering timeout (10s)
waitingForAnswer: 30000, // Waiting for answer timeout (30s)
creatingAnswer: 10000, // Creating answer timeout (10s)
iceConnection: 30000 // ICE connection timeout (30s)
}
});
``` ```
## Platform-Specific Setup ## Platform-Specific Setup
@@ -197,6 +322,43 @@ const client = new Rondevu({
}); });
``` ```
### Node.js with WebRTC (wrtc)
For WebRTC functionality in Node.js, you need to provide WebRTC polyfills since Node.js doesn't have native WebRTC support:
```bash
npm install wrtc node-fetch
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
// Now you can use WebRTC features
await client.register();
const peer = client.createPeer({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
// Create offers, answer, etc.
const offerId = await peer.createOffer({
topics: ['my-topic']
});
```
**Note:** The `wrtc` package provides WebRTC bindings for Node.js. Alternative packages like `node-webrtc` can also be used - just pass their implementations to the Rondevu constructor.
### Deno ### Deno
```typescript ```typescript
@@ -230,7 +392,7 @@ export default {
## Low-Level API Usage ## Low-Level API Usage
For advanced use cases where you need direct control over the signaling process, you can use the low-level API: For direct control over the signaling process without WebRTC:
```typescript ```typescript
import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client'; import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';
@@ -248,7 +410,9 @@ localStorage.setItem('rondevu-creds', JSON.stringify(creds));
const offers = await client.offers.create([{ const offers = await client.offers.create([{
sdp: 'v=0...', // Your WebRTC offer SDP sdp: 'v=0...', // Your WebRTC offer SDP
topics: ['movie-xyz', 'hd-content'], topics: ['movie-xyz', 'hd-content'],
ttl: 300000 // 5 minutes ttl: 300000, // 5 minutes
secret: 'my-secret-password', // Optional: protect offer (max 128 chars)
info: 'Looking for peers in EU region' // Optional: public info (max 128 chars)
}]); }]);
// Discover peers by topic // Discover peers by topic
@@ -273,14 +437,26 @@ const newPeers = await client.offers.findByTopic('movie-xyz', {
### Authentication ### Authentication
#### `client.register()` #### `client.register(customPeerId?)`
Register a new peer and receive credentials. Register a new peer and receive credentials.
```typescript ```typescript
// Auto-generate peer ID
const creds = await client.register(); const creds = await client.register();
// { peerId: '...', secret: '...' } // { peerId: 'f17c195f067255e357232e34cf0735d9', secret: '...' }
// Or use a custom peer ID (1-128 characters)
const customCreds = await client.register('my-custom-peer-id');
// { peerId: 'my-custom-peer-id', secret: '...' }
``` ```
**Parameters:**
- `customPeerId` (optional): Custom peer ID (1-128 characters). If not provided, a random ID will be generated.
**Notes:**
- Returns 409 Conflict if the custom peer ID is already in use
- Custom peer IDs must be non-empty and between 1-128 characters
### Topics ### Topics
#### `client.offers.getTopics(options?)` #### `client.offers.getTopics(options?)`
@@ -313,7 +489,9 @@ const offers = await client.offers.create([
{ {
sdp: 'v=0...', sdp: 'v=0...',
topics: ['topic-1', 'topic-2'], topics: ['topic-1', 'topic-2'],
ttl: 300000 // optional, default 5 minutes ttl: 300000, // optional, default 5 minutes
secret: 'my-secret-password', // optional, max 128 chars
info: 'Looking for peers in EU region' // optional, public info, max 128 chars
} }
]); ]);
``` ```
@@ -335,13 +513,6 @@ Get all offers owned by the authenticated peer.
const myOffers = await client.offers.getMine(); 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)` #### `client.offers.delete(offerId)`
Delete a specific offer. Delete a specific offer.
@@ -349,13 +520,18 @@ Delete a specific offer.
await client.offers.delete(offerId); await client.offers.delete(offerId);
``` ```
#### `client.offers.answer(offerId, sdp)` #### `client.offers.answer(offerId, sdp, secret?)`
Answer an offer (locks it to answerer). Answer an offer (locks it to answerer).
```typescript ```typescript
await client.offers.answer(offerId, answerSdp); await client.offers.answer(offerId, answerSdp, 'my-secret-password');
``` ```
**Parameters:**
- `offerId`: The offer ID to answer
- `sdp`: The WebRTC answer SDP
- `secret` (optional): Required if the offer has `hasSecret: true`
#### `client.offers.getAnswers()` #### `client.offers.getAnswers()`
Poll for answers to your offers. Poll for answers to your offers.
@@ -370,7 +546,7 @@ Post ICE candidates for an offer.
```typescript ```typescript
await client.offers.addIceCandidates(offerId, [ await client.offers.addIceCandidates(offerId, [
'candidate:1 1 UDP...' { candidate: 'candidate:1 1 UDP...', sdpMid: '0', sdpMLineIndex: 0 }
]); ]);
``` ```
@@ -378,7 +554,7 @@ await client.offers.addIceCandidates(offerId, [
Get ICE candidates from the other peer. Get ICE candidates from the other peer.
```typescript ```typescript
const candidates = await client.offers.getIceCandidates(offerId); const candidates = await client.offers.getIceCandidates(offerId, since);
``` ```
### Bloom Filter ### Bloom Filter
@@ -414,8 +590,9 @@ import type {
IceCandidate, IceCandidate,
FetchFunction, FetchFunction,
RondevuOptions, RondevuOptions,
ConnectionOptions, PeerOptions,
RondevuConnectionEvents PeerEvents,
PeerTimeouts
} from '@xtr-dev/rondevu-client'; } from '@xtr-dev/rondevu-client';
``` ```
@@ -423,28 +600,36 @@ import type {
The client library is designed to work across different JavaScript runtimes: The client library is designed to work across different JavaScript runtimes:
| Environment | Native Fetch | Custom Fetch Needed | | Environment | Native Fetch | Native WebRTC | Polyfills Needed |
|-------------|--------------|---------------------| |-------------|--------------|---------------|------------------|
| Modern Browsers | ✅ Yes | ❌ No | | Modern Browsers | ✅ Yes | ✅ Yes | ❌ None |
| Node.js 18+ | ✅ Yes | ❌ No | | Node.js 18+ | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) |
| Node.js < 18 | ❌ No | ✅ Yes (node-fetch) | | Node.js < 18 | ❌ No | ❌ No | ✅ Fetch + WebRTC |
| Deno | ✅ Yes | ❌ No | | Deno | ✅ Yes | ⚠️ Partial | ❌ None (signaling only) |
| Bun | ✅ Yes | ❌ No | | Bun | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) |
| Cloudflare Workers | ✅ Yes | ❌ No | | Cloudflare Workers | ✅ Yes | ❌ No | ❌ None (signaling only) |
**If your environment doesn't have native fetch:** **For signaling-only (no WebRTC peer connections):**
Use the low-level API with `client.offers` - no WebRTC polyfills needed.
**For full WebRTC support in Node.js:**
```bash ```bash
npm install node-fetch npm install wrtc node-fetch
``` ```
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({ const client = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev', baseUrl: 'https://api.ronde.vu',
fetch: fetch as any fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
}); });
``` ```

39
package-lock.json generated Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.7.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.7.11",
"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,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.4.1", "version": "0.7.11",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling 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",
@@ -25,5 +25,8 @@
"files": [ "files": [
"dist", "dist",
"README.md" "README.md"
] ],
"dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1"
}
} }

View File

@@ -29,14 +29,21 @@ export class RondevuAuth {
/** /**
* Register a new peer and receive credentials * Register a new peer and receive credentials
* @param customPeerId - Optional custom peer ID (1-128 characters). If not provided, a random ID will be generated.
* @throws Error if registration fails (e.g., peer ID already in use)
*/ */
async register(): Promise<Credentials> { async register(customPeerId?: string): Promise<Credentials> {
const body: { peerId?: string } = {};
if (customPeerId !== undefined) {
body.peerId = customPeerId;
}
const response = await this.fetchFn(`${this.baseUrl}/register`, { const response = await this.fetchFn(`${this.baseUrl}/register`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({}), body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -1,208 +0,0 @@
import {
RondevuClientOptions,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
VersionResponse,
HealthResponse,
ErrorResponse,
Side,
} from './types.js';
/**
* HTTP API client for Rondevu peer signaling server
*/
export class RondevuAPI {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu API client instance
* @param options - Client configuration options
*/
constructor(options: RondevuClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis);
}
/**
* Makes an HTTP request to the Rondevu server
*/
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await this.fetchImpl(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
const error = data as ErrorResponse;
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return data as T;
}
/**
* Gets server version information
*
* @returns Server version
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { version } = await api.getVersion();
* console.log('Server version:', version);
* ```
*/
async getVersion(): Promise<VersionResponse> {
return this.request<VersionResponse>('/', {
method: 'GET',
});
}
/**
* Creates a new offer
*
* @param request - Offer details including peer ID, signaling data, and optional custom code
* @returns Unique offer code (UUID or custom code)
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { code } = await api.createOffer({
* peerId: 'peer-123',
* offer: signalingData,
* code: 'my-custom-code' // optional
* });
* console.log('Offer code:', code);
* ```
*/
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 offer
*
* @param request - Answer details including offer code and signaling data
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Send answer
* await api.sendAnswer({
* code: offerCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await api.sendAnswer({
* code: offerCode,
* candidate: candidateData,
* side: 'offerer'
* });
* ```
*/
async sendAnswer(request: AnswerRequest): Promise<AnswerResponse> {
return this.request<AnswerResponse>('/answer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Polls for offer data from the other peer
*
* @param code - Offer code
* @param side - Which side is polling ('offerer' or 'answerer')
* @returns Offer data including offers, answers, and candidates
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await api.poll(offerCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await api.poll(offerCode, 'answerer');
* console.log('Received offer:', answererData.offer);
* ```
*/
async poll(
code: string,
side: Side
): Promise<PollOffererResponse | PollAnswererResponse> {
const request: PollRequest = { code, side };
return this.request<PollOffererResponse | PollAnswererResponse>('/poll', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Checks server health and version
*
* @returns Health status, timestamp, and version
*
* @example
* ```typescript
* 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> {
return this.request<HealthResponse>('/health', {
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,388 +0,0 @@
import { RondevuOffers, RTCIceCandidateInit } from './offers.js';
/**
* Events emitted by RondevuConnection
*/
export interface RondevuConnectionEvents {
'connecting': () => void;
'connected': () => void;
'disconnected': () => void;
'error': (error: Error) => void;
'datachannel': (channel: RTCDataChannel) => void;
'track': (event: RTCTrackEvent) => void;
}
/**
* Options for creating a WebRTC connection
*/
export interface ConnectionOptions {
/**
* RTCConfiguration for the peer connection
* @default { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
*/
rtcConfig?: RTCConfiguration;
/**
* Topics to advertise this connection under
*/
topics: string[];
/**
* How long the offer should live (milliseconds)
* @default 300000 (5 minutes)
*/
ttl?: number;
/**
* Whether to create a data channel automatically (for offerer)
* @default true
*/
createDataChannel?: boolean;
/**
* Label for the automatically created data channel
* @default 'data'
*/
dataChannelLabel?: string;
}
/**
* High-level WebRTC connection manager for Rondevu
* Handles offer/answer exchange, ICE candidates, and connection lifecycle
*/
export class RondevuConnection {
private pc: RTCPeerConnection;
private offersApi: RondevuOffers;
private offerId?: string;
private role?: 'offerer' | 'answerer';
private icePollingInterval?: ReturnType<typeof setInterval>;
private answerPollingInterval?: ReturnType<typeof setInterval>;
private lastIceTimestamp: number = 0; // Start at 0 to get all candidates on first poll
private eventListeners: Map<keyof RondevuConnectionEvents, Set<Function>> = new Map();
private dataChannel?: RTCDataChannel;
private pendingIceCandidates: RTCIceCandidateInit[] = [];
/**
* Current connection state
*/
get connectionState(): RTCPeerConnectionState {
return this.pc.connectionState;
}
/**
* The offer ID for this connection
*/
get id(): string | undefined {
return this.offerId;
}
/**
* Get the primary data channel (if created)
*/
get channel(): RTCDataChannel | undefined {
return this.dataChannel;
}
constructor(
offersApi: RondevuOffers,
private rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
) {
this.offersApi = offersApi;
this.pc = new RTCPeerConnection(rtcConfig);
this.setupPeerConnection();
}
/**
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.pc.onicecandidate = async (event) => {
if (event.candidate) {
// 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,
};
if (this.offerId) {
// offerId is set, send immediately (trickle ICE)
try {
await this.offersApi.addIceCandidates(this.offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
} 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);
};
}
/**
* Flush buffered ICE candidates (trickle ICE support)
*/
private async flushPendingIceCandidates(): Promise<void> {
if (this.pendingIceCandidates.length > 0 && this.offerId) {
try {
await this.offersApi.addIceCandidates(this.offerId, this.pendingIceCandidates);
this.pendingIceCandidates = [];
} catch (err) {
console.error('Error flushing pending ICE candidates:', err);
}
}
}
/**
* Create an offer and advertise on topics
*/
async createOffer(options: ConnectionOptions): Promise<string> {
this.role = 'offerer';
// Create data channel if requested
if (options.createDataChannel !== false) {
this.dataChannel = this.pc.createDataChannel(
options.dataChannelLabel || 'data'
);
this.emit('datachannel', this.dataChannel);
}
// Create WebRTC offer
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
// Create offer on Rondevu server
const offers = await this.offersApi.create([{
sdp: offer.sdp!,
topics: options.topics,
ttl: options.ttl || 300000
}]);
this.offerId = offers[0].id;
// Flush any ICE candidates that were generated during offer creation
await this.flushPendingIceCandidates();
// Start polling for answers
this.startAnswerPolling();
return this.offerId;
}
/**
* Answer an existing offer
*/
async answer(offerId: string, offerSdp: string): Promise<void> {
this.role = 'answerer';
// Set remote description
await this.pc.setRemoteDescription({
type: 'offer',
sdp: offerSdp
});
// Create answer
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
// Send answer to server FIRST
// This registers us as the answerer before ICE candidates arrive
await this.offersApi.answer(offerId, answer.sdp!);
// Now set offerId to enable ICE candidate sending
// This prevents a race condition where ICE candidates arrive before answer is registered
this.offerId = offerId;
// Flush any ICE candidates that were generated during answer creation
await this.flushPendingIceCandidates();
// Start polling for ICE candidates
this.startIcePolling();
}
/**
* Start polling for answers (offerer only)
*/
private startAnswerPolling(): void {
if (this.role !== 'offerer' || !this.offerId) return;
this.answerPollingInterval = setInterval(async () => {
try {
const answers = await this.offersApi.getAnswers();
const myAnswer = answers.find(a => a.offerId === this.offerId);
if (myAnswer) {
// Set remote description
await this.pc.setRemoteDescription({
type: 'answer',
sdp: myAnswer.sdp
});
// Stop answer polling, start ICE polling
this.stopAnswerPolling();
this.startIcePolling();
}
} catch (err) {
console.error('Error polling for answers:', err);
// Stop polling if offer expired/not found
if (err instanceof Error && err.message.includes('not found')) {
this.stopPolling();
}
}
}, 2000);
}
/**
* 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();
}
}
}, 1000);
}
/**
* Stop answer polling
*/
private stopAnswerPolling(): void {
if (this.answerPollingInterval) {
clearInterval(this.answerPollingInterval);
this.answerPollingInterval = undefined;
}
}
/**
* Stop ICE polling
*/
private stopIcePolling(): void {
if (this.icePollingInterval) {
clearInterval(this.icePollingInterval);
this.icePollingInterval = undefined;
}
}
/**
* 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 {
this.stopPolling();
this.pc.close();
this.eventListeners.clear();
}
}

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 { export class EventEmitter<EventMap extends Record<string, (...args: any[]) => void>> {
private events: Map<string, Set<Function>>; private events: Map<keyof EventMap, Set<Function>> = new Map();
constructor() {
this.events = new Map();
}
/** /**
* Register an event listener * 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)) { if (!this.events.has(event)) {
this.events.set(event, new Set()); this.events.set(event, new Set());
} }
@@ -22,18 +42,18 @@ export class EventEmitter {
/** /**
* Register a one-time event listener * Register a one-time event listener
*/ */
once(event: string, listener: Function): this { once<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
const onceWrapper = (...args: any[]) => { const onceWrapper = (...args: Parameters<EventMap[K]>) => {
this.off(event, onceWrapper); this.off(event, onceWrapper as EventMap[K]);
listener.apply(this, args); listener(...args);
}; };
return this.on(event, onceWrapper); return this.on(event, onceWrapper as EventMap[K]);
} }
/** /**
* Remove an event listener * 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); const listeners = this.events.get(event);
if (listeners) { if (listeners) {
listeners.delete(listener); listeners.delete(listener);
@@ -47,7 +67,10 @@ export class EventEmitter {
/** /**
* Emit an event * 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); const listeners = this.events.get(event);
if (!listeners || listeners.size === 0) { if (!listeners || listeners.size === 0) {
return false; return false;
@@ -55,9 +78,9 @@ export class EventEmitter {
listeners.forEach(listener => { listeners.forEach(listener => {
try { try {
listener.apply(this, args); (listener as EventMap[K])(...args);
} catch (err) { } 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) * Remove all listeners for an event (or all events if not specified)
*/ */
removeAllListeners(event?: string): this { removeAllListeners<K extends keyof EventMap>(event?: K): this {
if (event) { if (event !== undefined) {
this.events.delete(event); this.events.delete(event);
} else { } else {
this.events.clear(); this.events.clear();
@@ -79,7 +102,7 @@ export class EventEmitter {
/** /**
* Get listener count for an event * Get listener count for an event
*/ */
listenerCount(event: string): number { listenerCount<K extends keyof EventMap>(event: K): number {
const listeners = this.events.get(event); const listeners = this.events.get(event);
return listeners ? listeners.size : 0; return listeners ? listeners.size : 0;
} }

View File

@@ -23,9 +23,10 @@ export type {
// Export bloom filter // Export bloom filter
export { BloomFilter } from './bloom.js'; export { BloomFilter } from './bloom.js';
// Export connection manager // Export peer manager
export { RondevuConnection } from './connection.js'; export { default as RondevuPeer } from './peer/index.js';
export type { export type {
ConnectionOptions, PeerOptions,
RondevuConnectionEvents PeerEvents,
} from './connection.js'; PeerTimeouts
} from './peer/index.js';

View File

@@ -5,10 +5,11 @@ import { RondevuAuth } from './auth.js';
declare const Buffer: any; declare const Buffer: any;
export interface CreateOfferRequest { export interface CreateOfferRequest {
id?: string;
sdp: string; sdp: string;
topics: string[]; topics: string[];
ttl?: number; ttl?: number;
secret?: string;
info?: string;
} }
export interface Offer { export interface Offer {
@@ -19,23 +20,16 @@ export interface Offer {
createdAt?: number; createdAt?: number;
expiresAt: number; expiresAt: number;
lastSeen: number; lastSeen: number;
secret?: string;
hasSecret?: boolean;
info?: string;
answererPeerId?: string; answererPeerId?: string;
answerSdp?: string; answerSdp?: string;
answeredAt?: number; 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 { export interface IceCandidate {
candidate: RTCIceCandidateInit; // Full candidate object candidate: any; // Full candidate object as plain JSON - don't enforce structure
peerId: string; peerId: string;
role: 'offerer' | 'answerer'; role: 'offerer' | 'answerer';
createdAt: number; createdAt: number;
@@ -154,11 +148,13 @@ export class RondevuOffers {
async getTopics(options?: { async getTopics(options?: {
limit?: number; limit?: number;
offset?: number; offset?: number;
startsWith?: string;
}): Promise<{ }): Promise<{
topics: TopicInfo[]; topics: TopicInfo[];
total: number; total: number;
limit: number; limit: number;
offset: number; offset: number;
startsWith?: string;
}> { }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -170,6 +166,10 @@ export class RondevuOffers {
params.set('offset', options.offset.toString()); params.set('offset', options.offset.toString());
} }
if (options?.startsWith) {
params.set('startsWith', options.startsWith);
}
const url = `${this.baseUrl}/topics${ const url = `${this.baseUrl}/topics${
params.toString() ? '?' + params.toString() : '' params.toString() ? '?' + params.toString() : ''
}`; }`;
@@ -206,23 +206,6 @@ export class RondevuOffers {
return data.offers; 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 * Delete an offer
*/ */
@@ -243,14 +226,14 @@ export class RondevuOffers {
/** /**
* Answer an offer * Answer an offer
*/ */
async answer(offerId: string, sdp: string): Promise<void> { async answer(offerId: string, sdp: string, secret?: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/answer`, { const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/answer`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: RondevuAuth.createAuthHeader(this.credentials), Authorization: RondevuAuth.createAuthHeader(this.credentials),
}, },
body: JSON.stringify({ sdp }), body: JSON.stringify({ sdp, secret }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -290,7 +273,7 @@ export class RondevuOffers {
*/ */
async addIceCandidates( async addIceCandidates(
offerId: string, offerId: string,
candidates: RTCIceCandidateInit[] candidates: any[]
): Promise<void> { ): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates`, { const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates`, {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,49 @@
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();
// Send answer to server BEFORE setLocalDescription
// This registers us as the answerer so ICE candidates will be accepted
await this.peer.offersApi.answer(offerId, answer.sdp!, options.secret);
// Enable trickle ICE - set up handler before ICE gathering starts
this.setupIceCandidateHandler();
// Set local description - ICE gathering starts here
// Server already knows we're the answerer, so candidates will be accepted
await this.peer.pc.setLocalDescription(answer);
// 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,57 @@
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);
}
// Enable trickle ICE - set up handler before ICE gathering starts
// Handler will check this.peer.offerId before sending
this.setupIceCandidateHandler();
// Create WebRTC offer
const offer = await this.peer.pc.createOffer();
await this.peer.pc.setLocalDescription(offer); // ICE gathering starts here
// 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,
secret: options.secret
}]);
const offerId = offers[0].id;
this.peer.offerId = offerId; // Now handler can send candidates
// 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 this.peer.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);
}
}

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

@@ -0,0 +1,212 @@
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';
// WebRTC polyfills for Node.js compatibility
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
private _state: PeerState;
// Event handler references for cleanup
private connectionStateChangeHandler?: () => void;
private dataChannelHandler?: (event: RTCDataChannelEvent) => void;
private trackHandler?: (event: RTCTrackEvent) => void;
private iceCandidateErrorHandler?: (event: Event) => void;
/**
* 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' }
]
},
rtcPeerConnection?: typeof RTCPeerConnection,
rtcSessionDescription?: typeof RTCSessionDescription,
rtcIceCandidate?: typeof RTCIceCandidate
) {
super();
this.offersApi = offersApi;
// Use provided polyfills or fall back to globals
this.RTCPeerConnection = rtcPeerConnection || (typeof globalThis.RTCPeerConnection !== 'undefined'
? globalThis.RTCPeerConnection
: (() => {
throw new Error('RTCPeerConnection is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCSessionDescription = rtcSessionDescription || (typeof globalThis.RTCSessionDescription !== 'undefined'
? globalThis.RTCSessionDescription
: (() => {
throw new Error('RTCSessionDescription is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCIceCandidate = rtcIceCandidate || (typeof globalThis.RTCIceCandidate !== 'undefined'
? globalThis.RTCIceCandidate
: (() => {
throw new Error('RTCIceCandidate is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.pc = new this.RTCPeerConnection(rtcConfig);
this._state = new IdleState(this);
this.setupPeerConnection();
}
/**
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.connectionStateChangeHandler = () => {
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.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
this.dataChannelHandler = (event: RTCDataChannelEvent) => {
this.emitEvent('datachannel', event.channel);
};
this.pc.addEventListener('datachannel', this.dataChannelHandler);
this.trackHandler = (event: RTCTrackEvent) => {
this.emitEvent('track', event);
};
this.pc.addEventListener('track', this.trackHandler);
this.iceCandidateErrorHandler = (event: Event) => {
console.error('ICE candidate error:', event);
};
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
/**
* 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);
}
/**
* Create a data channel for sending and receiving arbitrary data
* This should typically be called by the offerer before creating the offer
* The answerer will receive the channel via the 'datachannel' event
*/
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
return this.pc.createDataChannel(label, options);
}
/**
* Close the connection and clean up
*/
async close(): Promise<void> {
// Remove RTCPeerConnection event listeners
if (this.connectionStateChangeHandler) {
this.pc.removeEventListener('connectionstatechange', this.connectionStateChangeHandler);
}
if (this.dataChannelHandler) {
this.pc.removeEventListener('datachannel', this.dataChannelHandler);
}
if (this.trackHandler) {
this.pc.removeEventListener('track', this.trackHandler);
}
if (this.iceCandidateErrorHandler) {
this.pc.removeEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
await this._state.close();
this.removeAllListeners();
}
}

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

@@ -0,0 +1,66 @@
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 {
protected iceCandidateHandler?: (event: RTCPeerConnectionIceEvent) => void;
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 this.peer.RTCIceCandidate(candidate));
}
}
/**
* Setup trickle ICE candidate handler
* Sends local ICE candidates to server as they are discovered
*/
protected setupIceCandidateHandler(): void {
this.iceCandidateHandler = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && this.peer.offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(this.peer.offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);
}
cleanup(): void {
// Clean up ICE candidate handler if it exists
if (this.iceCandidateHandler) {
this.peer.pc.removeEventListener('icecandidate', this.iceCandidateHandler);
}
}
async close(): Promise<void> {
this.cleanup();
const { ClosedState } = await import('./closed-state.js');
this.peer.setState(new ClosedState(this.peer));
}
}

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

@@ -0,0 +1,45 @@
/**
* 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;
/** Optional secret to protect the offer (max 128 characters) */
secret?: string;
/** 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,6 +1,6 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.js'; import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js'; import { RondevuOffers } from './offers.js';
import { RondevuConnection, ConnectionOptions } from './connection.js'; import RondevuPeer from './peer/index.js';
export interface RondevuOptions { export interface RondevuOptions {
/** /**
@@ -25,6 +25,42 @@ export interface RondevuOptions {
* ``` * ```
*/ */
fetch?: FetchFunction; fetch?: FetchFunction;
/**
* Custom RTCPeerConnection implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCPeerConnection } from 'wrtc';
* const client = new Rondevu({ RTCPeerConnection });
* ```
*/
RTCPeerConnection?: typeof RTCPeerConnection;
/**
* Custom RTCSessionDescription implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCSessionDescription } from 'wrtc';
* const client = new Rondevu({ RTCSessionDescription });
* ```
*/
RTCSessionDescription?: typeof RTCSessionDescription;
/**
* Custom RTCIceCandidate implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCIceCandidate } from 'wrtc';
* const client = new Rondevu({ RTCIceCandidate });
* ```
*/
RTCIceCandidate?: typeof RTCIceCandidate;
} }
export class Rondevu { export class Rondevu {
@@ -33,10 +69,16 @@ export class Rondevu {
private credentials?: Credentials; private credentials?: Credentials;
private baseUrl: string; private baseUrl: string;
private fetchFn?: FetchFunction; private fetchFn?: FetchFunction;
private rtcPeerConnection?: typeof RTCPeerConnection;
private rtcSessionDescription?: typeof RTCSessionDescription;
private rtcIceCandidate?: typeof RTCIceCandidate;
constructor(options: RondevuOptions = {}) { constructor(options: RondevuOptions = {}) {
this.baseUrl = options.baseUrl || 'https://api.ronde.vu'; this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
this.fetchFn = options.fetch; this.fetchFn = options.fetch;
this.rtcPeerConnection = options.RTCPeerConnection;
this.rtcSessionDescription = options.RTCSessionDescription;
this.rtcIceCandidate = options.RTCIceCandidate;
this.auth = new RondevuAuth(this.baseUrl, this.fetchFn); this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
@@ -58,9 +100,10 @@ export class Rondevu {
/** /**
* Register and initialize authenticated client * Register and initialize authenticated client
* @param customPeerId - Optional custom peer ID (1-128 characters). If not provided, a random ID will be generated.
*/ */
async register(): Promise<Credentials> { async register(customPeerId?: string): Promise<Credentials> {
this.credentials = await this.auth.register(); this.credentials = await this.auth.register(customPeerId);
// Create offers API instance // Create offers API instance
this._offers = new RondevuOffers( this._offers = new RondevuOffers(
@@ -87,17 +130,23 @@ export class Rondevu {
} }
/** /**
* Create a new WebRTC connection (requires authentication) * Create a new WebRTC peer connection (requires authentication)
* This is a high-level helper that creates and manages WebRTC connections * This is a high-level helper that creates and manages WebRTC connections with state management
* *
* @param rtcConfig Optional RTCConfiguration for the peer connection * @param rtcConfig Optional RTCConfiguration for the peer connection
* @returns RondevuConnection instance * @returns RondevuPeer instance
*/ */
createConnection(rtcConfig?: RTCConfiguration): RondevuConnection { createPeer(rtcConfig?: RTCConfiguration): RondevuPeer {
if (!this._offers) { if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.'); throw new Error('Not authenticated. Call register() first or provide credentials.');
} }
return new RondevuConnection(this._offers, rtcConfig); return new RondevuPeer(
this._offers,
rtcConfig,
this.rtcPeerConnection,
this.rtcSessionDescription,
this.rtcIceCandidate
);
} }
} }

View File

@@ -1,182 +0,0 @@
// ============================================================================
// Signaling Types
// ============================================================================
/**
* Session side - identifies which peer in a connection
*/
export type Side = 'offerer' | 'answerer';
/**
* Request body for POST /offer
*/
export interface CreateOfferRequest {
/** Peer identifier/metadata (max 1024 characters) */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Optional custom connection code (if not provided, server generates UUID) */
code?: string;
}
/**
* Response from POST /offer
*/
export interface CreateOfferResponse {
/** Unique session identifier (UUID) */
code: string;
}
/**
* Request body for POST /answer
*/
export interface AnswerRequest {
/** Session UUID from the offer */
code: string;
/** Response signaling data (required if candidate not provided) */
answer?: string;
/** Additional signaling data (required if answer not provided) */
candidate?: string;
/** Which peer is sending the data */
side: Side;
}
/**
* Response from POST /answer
*/
export interface AnswerResponse {
success: boolean;
}
/**
* Request body for POST /poll
*/
export interface PollRequest {
/** Session UUID */
code: string;
/** Which side is polling */
side: Side;
}
/**
* Response from POST /poll when side=offerer
*/
export interface PollOffererResponse {
/** Answer from answerer (null if not yet received) */
answer: string | null;
/** Additional signaling data from answerer */
answerCandidates: string[];
}
/**
* Response from POST /poll when side=answerer
*/
export interface PollAnswererResponse {
/** Offer from offerer */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
}
/**
* Response from POST /poll (union type)
*/
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;
}
/**
* Error response structure
*/
export interface ErrorResponse {
error: string;
}
/**
* Client configuration options
*/
export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
// ============================================================================
// 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 (defaults to 'https://api.ronde.vu') */
baseUrl?: string;
/** Peer identifier (optional, auto-generated if not provided) */
peerId?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */
rtcConfig?: RTCConfiguration;
/** Polling interval in milliseconds (default: 1000) */
pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number;
/** WebRTC polyfill for Node.js (e.g., wrtc or @roamhq/wrtc) */
wrtc?: WebRTCPolyfill;
}
/**
* Connection role - whether this peer is creating or answering
*/
export type ConnectionRole = 'offerer' | 'answerer';
/**
* Parameters for creating a RondevuConnection
*/
export interface RondevuConnectionParams {
id: string;
topic?: string;
role: ConnectionRole;
pc: RTCPeerConnection;
localPeerId: string;
remotePeerId: string;
pollingInterval: number;
connectionTimeout: number;
wrtc?: WebRTCPolyfill;
}
/**
* Event map for RondevuConnection events
*/
export interface RondevuConnectionEvents {
connect: () => void;
disconnect: () => void;
error: (error: Error) => void;
datachannel: (channel: RTCDataChannel) => void;
stream: (stream: MediaStream) => void;
}