61 Commits

Author SHA1 Message Date
895e7765f9 Update README: Remove custom peer ID documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:22:58 +01:00
49d3984640 Remove custom peer ID feature for security
Always generate cryptographically random 128-bit peer IDs to prevent peer ID hijacking vulnerability. This ensures peer IDs are secure through collision resistance rather than relying on expiration-based protection.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:19:07 +01:00
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
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 1957 additions and 1104 deletions

638
README.md
View File

@@ -1,60 +1,628 @@
# Rondevu
🎯 Meet WebRTC peers by topic, by peer ID, or by connection ID.
## @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)
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 with trickle ICE support.
**Related repositories:**
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
- [@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))
---
## Features
- **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes)
- **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
- **Multi-Offer Management**: Create and manage multiple offers per peer
- **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
## Install
```bash
npm install @xtr-dev/rondevu-client
```
### Usage
## Quick Start
### Creating an Offer (Peer A)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
const rdv = new Rondevu({ baseUrl: 'https://server.com' });
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Connect by topic
const conn = await rdv.join('room');
// Create peer connection
const peer = client.createPeer();
// Or connect by ID
const conn = await rdv.connect('meeting-123');
// Set up event listeners
peer.on('state', (state) => {
console.log('Peer state:', state);
// States: idle → creating-offer → waiting-for-answer → exchanging-ice → connected
});
// Use the connection
conn.on('connect', () => {
const channel = conn.dataChannel('chat');
channel.send('Hello!');
peer.on('connected', () => {
console.log('✅ Connected to peer!');
});
peer.on('datachannel', (channel) => {
console.log('📡 Data channel ready');
channel.addEventListener('message', (event) => {
console.log('📥 Received:', event.data);
});
channel.addEventListener('open', () => {
channel.send('Hello from peer A!');
});
});
// Create offer and advertise on topics
const offerId = await peer.createOffer({
topics: ['my-app', 'room-123'],
ttl: 300000, // 5 minutes
secret: 'my-secret-password' // Optional: protect offer (max 128 chars)
});
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';
// Initialize client and register
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 peer connection
const peer = client.createPeer();
// Set up event listeners
peer.on('state', (state) => {
console.log('Peer state:', state);
// States: idle → answering → exchanging-ice → connected
});
peer.on('connected', () => {
console.log('✅ Connected!');
});
peer.on('datachannel', (channel) => {
console.log('📡 Data channel ready');
channel.addEventListener('message', (event) => {
console.log('📥 Received:', event.data);
});
channel.addEventListener('open', () => {
channel.send('Hello from peer B!');
});
});
peer.on('failed', (error) => {
console.error('❌ Connection failed:', error);
});
// Answer the offer
await peer.answer(offer.id, offer.sdp, {
topics: offer.topics,
secret: 'my-secret-password' // Required if offer.hasSecret is true
});
}
```
## 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
const offerId = await peer.createOffer({
topics: ['private-room'],
secret: 'my-secret-password' // Max 128 characters
});
// 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
});
```
### API
**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)
**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
## Connection Lifecycle
**Connection Events:**
- `connect` - Connection established
- `disconnect` - Connection closed
- `datachannel` - Remote peer created data channel
- `stream` - Remote media stream received
- `error` - Error occurred
The `RondevuPeer` uses a state machine for connection management:
**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
### 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
### License
### 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
});
peer.on('disconnected', () => {
// Connection lost or closed
});
peer.on('failed', (error) => {
// Connection failed
console.error('Connection error:', error);
});
peer.on('datachannel', (channel) => {
// Data channel is ready (use channel.addEventListener)
});
peer.on('track', (event) => {
// Media track received (for audio/video streaming)
const stream = event.streams[0];
videoElement.srcObject = stream;
});
```
## 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
// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// Add tracks to peer connection
stream.getTracks().forEach(track => {
peer.addTrack(track, stream);
});
```
## Peer Properties
```typescript
// Get current state name
console.log(peer.stateName); // 'idle', 'creating-offer', 'connected', etc.
// Get connection state
console.log(peer.connectionState); // RTCPeerConnectionState
// Get offer ID (after creating offer or answering)
console.log(peer.offerId);
// Get role
console.log(peer.role); // 'offerer' or 'answerer'
```
## Closing a Connection
```typescript
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
### 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
});
```
### 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
```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 direct control over the signaling process without WebRTC:
```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
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
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.
Generates a cryptographically random 128-bit peer ID.
```typescript
const creds = await client.register();
// { peerId: 'f17c195f067255e357232e34cf0735d9', 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
secret: 'my-secret-password', // optional, max 128 chars
info: 'Looking for peers in EU region' // optional, public info, max 128 chars
}
]);
```
#### `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.delete(offerId)`
Delete a specific offer.
```typescript
await client.offers.delete(offerId);
```
#### `client.offers.answer(offerId, sdp, secret?)`
Answer an offer (locks it to answerer).
```typescript
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()`
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: 'candidate:1 1 UDP...', sdpMid: '0', sdpMLineIndex: 0 }
]);
```
#### `client.offers.getIceCandidates(offerId, since?)`
Get ICE candidates from the other peer.
```typescript
const candidates = await client.offers.getIceCandidates(offerId, since);
```
### 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,
PeerOptions,
PeerEvents,
PeerTimeouts
} from '@xtr-dev/rondevu-client';
```
## Environment Compatibility
The client library is designed to work across different JavaScript runtimes:
| Environment | Native Fetch | Native WebRTC | Polyfills Needed |
|-------------|--------------|---------------|------------------|
| Modern Browsers | ✅ Yes | ✅ Yes | ❌ None |
| Node.js 18+ | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) |
| Node.js < 18 | ❌ No | ❌ No | ✅ Fetch + WebRTC |
| Deno | ✅ Yes | ⚠️ Partial | ❌ None (signaling only) |
| Bun | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) |
| Cloudflare Workers | ✅ Yes | ❌ No | ❌ None (signaling only) |
**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
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
});
```
## License
MIT

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,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.12",
"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"
}
}

62
src/auth.ts Normal file
View File

@@ -0,0 +1,62 @@
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
* Generates a cryptographically random peer ID (128-bit)
* @throws Error if registration fails
*/
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,223 +0,0 @@
import {
RondevuClientOptions,
ListTopicsResponse,
ListSessionsResponse,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
HealthResponse,
ErrorResponse,
Side,
} from './types';
/**
* HTTP client for Rondevu peer signaling and discovery server
*/
export class RondevuClient {
private readonly baseUrl: string;
private readonly origin: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu 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);
}
/**
* 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> = {
'Origin': this.origin,
...(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;
}
/**
* Lists all topics with peer counts
*
* @param page - Page number (starting from 1)
* @param limit - Results per page (max 1000)
* @returns List of topics with pagination info
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { topics, pagination } = await client.listTopics();
* console.log(`Found ${topics.length} topics`);
* ```
*/
async listTopics(page = 1, limit = 100): Promise<ListTopicsResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
return this.request<ListTopicsResponse>(`/?${params}`, {
method: 'GET',
});
}
/**
* Discovers available peers for a given topic
*
* @param topic - Topic identifier
* @returns List of available sessions
*
* @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', {
* peerId: 'peer-123',
* offer: signalingData
* });
* console.log('Session code:', code);
* ```
*/
async createOffer(
topic: string,
request: CreateOfferRequest
): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>(
`/${encodeURIComponent(topic)}/offer`,
{
method: 'POST',
body: JSON.stringify(request),
}
);
}
/**
* Sends an answer or candidate to an existing session
*
* @param request - Answer details including session code and signaling data
* @returns Success confirmation
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
*
* // Send answer
* await client.sendAnswer({
* code: sessionCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await client.sendAnswer({
* code: sessionCode,
* candidate: candidateData,
* side: 'offerer'
* });
* ```
*/
async sendAnswer(request: AnswerRequest): Promise<AnswerResponse> {
return this.request<AnswerResponse>('/answer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Polls for session data from the other peer
*
* @param code - Session UUID
* @param side - Which side is polling ('offerer' or 'answerer')
* @returns Session data including offers, answers, and candidates
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await client.poll(sessionCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await client.poll(sessionCode, '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
*
* @returns Health status and timestamp
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const health = await client.health();
* console.log('Server status:', health.status);
* ```
*/
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health', {
method: 'GET',
});
}
}

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

321
src/offers.ts Normal file
View File

@@ -0,0 +1,321 @@
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;
secret?: string;
info?: string;
}
export interface Offer {
id: string;
peerId: string;
sdp: string;
topics: string[];
createdAt?: number;
expiresAt: number;
lastSeen: number;
secret?: string;
hasSecret?: boolean;
info?: string;
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;
startsWith?: string;
}): Promise<{
topics: TopicInfo[];
total: number;
limit: number;
offset: number;
startsWith?: string;
}> {
const params = new URLSearchParams();
if (options?.limit) {
params.set('limit', options.limit.toString());
}
if (options?.offset) {
params.set('offset', options.offset.toString());
}
if (options?.startsWith) {
params.set('startsWith', options.startsWith);
}
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;
}
/**
* 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, secret?: 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, secret }),
});
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,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,266 +1,152 @@
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';
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;
/**
* 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;
}
/**
* Main Rondevu WebRTC client with automatic connection management
*/
export class Rondevu {
readonly peerId: string;
readonly auth: RondevuAuth;
private _offers?: RondevuOffers;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
private rtcPeerConnection?: typeof RTCPeerConnection;
private rtcSessionDescription?: typeof RTCSessionDescription;
private rtcIceCandidate?: typeof RTCIceCandidate;
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;
this.rtcPeerConnection = options.RTCPeerConnection;
this.rtcSessionDescription = options.RTCSessionDescription;
this.rtcIceCandidate = options.RTCIceCandidate;
/**
* 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;
}
/**
* Generate a unique peer ID
*/
private generatePeerId(): string {
return `rdv_${Math.random().toString(36).substring(2, 14)}`;
}
/**
* Update the peer ID (useful when user identity changes)
*/
updatePeerId(newPeerId: string): void {
(this as any).peerId = newPeerId;
}
/**
* Create a new connection (offerer role)
* @param id - Connection identifier
* @param topic - Topic name for grouping connections
* @returns Promise that resolves to RondevuConnection
*/
async create(id: string, topic: string): Promise<RondevuConnection> {
// Create peer connection
const pc = new RTCPeerConnection(this.rtcConfig);
// Create initial data channel for negotiation (required for offer creation)
pc.createDataChannel('_negotiation');
// Generate offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering to complete
await this.waitForIceGathering(pc);
// Create session on server with custom code
await this.client.createOffer(topic, {
peerId: this.peerId,
offer: pc.localDescription!.sdp,
code: id,
});
// Create connection object
const connectionParams: RondevuConnectionParams = {
id,
topic,
role: 'offerer',
pc,
localPeerId: this.peerId,
remotePeerId: '', // Will be populated when answer is received
pollingInterval: this.pollingInterval,
connectionTimeout: this.connectionTimeout,
};
const connection = new RondevuConnection(connectionParams, this.client);
// Start polling for answer
connection.startPolling();
return connection;
}
/**
* Connect to an existing connection by ID (answerer role)
* @param id - Connection identifier
* @returns Promise that resolves to RondevuConnection
*/
async connect(id: string): Promise<RondevuConnection> {
// Poll server to get session by ID
const sessionData = await this.findSessionById(id);
if (!sessionData) {
throw new Error(`Connection ${id} not found or expired`);
if (options.credentials) {
this.credentials = options.credentials;
this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
}
// 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
* Get offers API (requires authentication)
*/
async join(topic: string, options?: JoinOptions): Promise<RondevuConnection> {
// List sessions in topic
const { sessions } = await this.client.listSessions(topic);
get offers(): RondevuOffers {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._offers;
}
// Filter out self (sessions with our peer ID)
let availableSessions = sessions.filter(
session => session.peerId !== this.peerId
/**
* Register and initialize authenticated client
* Generates a cryptographically random peer ID (128-bit)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create offers API instance
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
);
// Apply custom filter if provided
if (options?.filter) {
availableSessions = availableSessions.filter(options.filter);
return this.credentials;
}
/**
* Check if client is authenticated
*/
isAuthenticated(): boolean {
return !!this.credentials;
}
/**
* Get current credentials
*/
getCredentials(): Credentials | undefined {
return this.credentials;
}
/**
* 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
*/
createPeer(rtcConfig?: RTCConfiguration): RondevuPeer {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
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'
return new RondevuPeer(
this._offers,
rtcConfig,
this.rtcPeerConnection,
this.rtcSessionDescription,
this.rtcIceCandidate
);
// Connect to selected session
return this.connect(selectedSession.code);
}
/**
* Select a session based on strategy
*/
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];
}
}
/**
* Wait for ICE gathering to complete
*/
private async waitForIceGathering(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') {
return;
}
return new Promise((resolve) => {
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState);
// Also set a timeout in case gathering takes too long
setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}, 5000);
});
}
/**
* Find a session by connection ID
* This requires polling since we don't know which topic it's in
*/
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,
};
}
return null;
} catch (err) {
throw new Error(`Failed to find session ${id}: ${(err as Error).message}`);
}
}
}

View File

@@ -1,232 +0,0 @@
// ============================================================================
// Signaling Types
// ============================================================================
/**
* Session side - identifies which peer in a connection
*/
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
*/
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 /:topic/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 /health
*/
export interface HealthResponse {
status: 'ok';
timestamp: number;
}
/**
* 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;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
// ============================================================================
// WebRTC Types
// ============================================================================
/**
* Configuration options for Rondevu WebRTC client
*/
export interface RondevuOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
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.) */
rtcConfig?: RTCConfiguration;
/** Polling interval in milliseconds (default: 1000) */
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';
}
/**
* 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;
}
/**
* Event map for RondevuConnection events
*/
export interface RondevuConnectionEvents {
connect: () => void;
disconnect: () => void;
error: (error: Error) => void;
datachannel: (channel: RTCDataChannel) => void;
stream: (stream: MediaStream) => void;
}