59 Commits

Author SHA1 Message Date
a3b4dfa15f 0.13.0 2025-12-09 22:28:15 +01:00
5c38f8f36c v0.13.0: Major refactoring with unified Rondevu class and service discovery
- Renamed RondevuService to Rondevu as single main entrypoint
- Integrated signaling methods directly into Rondevu class
- Updated service FQN format: service:version@username (colon instead of @)
- Added service discovery (direct, random, paginated)
- Removed high-level abstractions (ServiceHost, ServiceClient, RTCDurableConnection, EventBus, WebRTCContext, Bin)
- Updated RondevuAPI with new endpoint methods (offer-specific routes)
- Simplified types (moved Binnable to types.ts, removed connection types)
- Updated RondevuSignaler to use Rondevu class
- Breaking changes: Complete API overhaul for simplicity

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:22:15 +01:00
177ee2ec2d feat: update client to use service-based signaling endpoints
BREAKING CHANGE: Client API now uses service UUIDs for WebRTC signaling

- Replace answerOffer() with answerService()
- Replace getAnswer() with getServiceAnswer()
- Replace addIceCandidates() with addServiceIceCandidates()
- Replace getIceCandidates() with getServiceIceCandidates()
- Update RondevuSignaler to use service UUID instead of offer ID for signaling
- Automatically track offerId returned from service endpoints
- Bump version to 0.12.0

Matches server v0.4.0 service-based API refactor.
2025-12-07 22:17:36 +01:00
d06b2166c1 chore: Bump version to 0.11.0 2025-12-07 22:01:34 +01:00
cbb0cc3f83 docs: Update README with semver and privacy features 2025-12-07 21:58:20 +01:00
fbd3be57d4 Add RTCConfiguration support to ServiceHost and ServiceClient
- WebRTCContext now accepts optional RTCConfiguration
- ServiceHost and ServiceClient accept optional rtcConfiguration option
- Allows custom STUN/TURN server configuration
- Version bump to 0.10.1

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:43:34 +01:00
54355323d9 Add ServiceHost, ServiceClient, and RondevuService for high-level service management
- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures
- Add ServiceHost: Manages offer pool for hosting services with auto-replacement
- Add ServiceClient: Connects to hosted services with automatic reconnection
- Add NoOpSignaler: Placeholder signaler for connection setup
- Integrate Ed25519 signature functionality from @noble/ed25519
- Add ESLint and Prettier configuration with 4-space indentation
- Add demo with local signaling test
- Version bump to 0.10.0

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:37:43 +01:00
945d5a8792 Add comprehensive usage documentation 2025-12-07 17:41:16 +01:00
58cd610694 Implement RondevuAPI and RondevuSignaler classes
Added comprehensive API client and signaling implementation:

**RondevuAPI** - Single class for all Rondevu endpoints:
- Authentication: register()
- Offers: createOffers(), getOffer(), answerOffer(), getAnswer(), searchOffers()
- ICE Candidates: addIceCandidates(), getIceCandidates()
- Services: publishService(), getService(), searchServices()
- Usernames: checkUsername(), claimUsername()

**RondevuSignaler** - ICE candidate exchange:
- addIceCandidate() - Send local candidates to server
- addListener() - Poll for remote candidates (1 second intervals)
- Returns cleanup function (Binnable) to stop polling
- Handles offer expiration gracefully

**WebRTCRondevuConnection** - WebRTC connection wrapper:
- Handles offer/answer creation
- Manages ICE candidate exchange via Signaler
- Type-safe event bus for state changes and messages
- Queue and send message interfaces

**Utilities**:
- createBin() - Cleanup function collector
- Binnable type - Cleanup function signature

All classes use the shared RondevuAPI client for consistent
error handling and authentication.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 17:40:17 +01:00
5e673ac993 Add type-safe EventBus with generic event mapping
Implemented EventBus class with full TypeScript type inference:
- Generic type parameter TEvents for event name to payload mapping
- Type-safe on/once/off/emit methods with inferred data types
- Utility methods: clear, listenerCount, eventNames
- Complete JSDoc documentation with usage examples

Added core connection types:
- ConnectionIdentity, ConnectionState, ConnectionInterface
- QueueMessageOptions for message queuing
- Connection composite type

All types and classes exported from main index.

Example usage:
```typescript
interface MyEvents {
  'user:connected': { userId: string; timestamp: number };
  'message:received': string;
}

const bus = new EventBus<MyEvents>();

// TypeScript knows data is { userId: string; timestamp: number }
bus.on('user:connected', (data) => {
  console.log(data.userId, data.timestamp);
});
```

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 16:17:52 +01:00
511bac8033 Strip client to minimal skeleton with ConnectionManager
Removed all complex implementations and kept only the essentials:
- Removed durable/ directory (DurableConnection, DurableChannel, etc.)
- Removed peer/ directory (entire state machine)
- Removed service-pool.ts, offer-pool.ts, rondevu.ts
- Removed auth.ts, offers.ts, usernames.ts, event-emitter.ts
- Added empty ConnectionManager class as starting point

The client now contains just:
- src/connection-manager.ts - Empty class skeleton
- src/index.ts - Simple export

This provides a clean slate to rebuild the client with a simpler
architecture focused on core WebRTC connection management.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 13:30:55 +01:00
eb2c61bdb8 Release v0.9.2: Fix service pool ICE candidate collection 2025-12-07 11:31:33 +01:00
3139897b25 Fix service pool ICE candidate collection and logging
Fixed critical timing issue where ICE candidates were generated before
the handler was attached, causing them to be lost:

- Set up onicecandidate handler BEFORE setLocalDescription()
- Collect candidates in array while waiting for offer ID
- Send all pending candidates once offer ID is available
- Add detailed logging for service pool ICE candidates
- Log candidate type (host/srflx/relay) for debugging

This ensures all ICE candidates are captured and sent to the signaling
server, and provides visibility into what types of candidates are being
generated (especially important for diagnosing TURN relay issues).

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 11:31:24 +01:00
a550641993 Release v0.9.1: Add detailed ICE candidate exchange logging 2025-12-07 11:13:32 +01:00
04603cfe2d Add detailed ICE candidate exchange logging
Added comprehensive logging to track WebRTC ICE candidate exchange:
- Log local candidate generation with type (host/srflx/relay)
- Log when candidates are sent to signaling server
- Log remote candidate reception and addition
- Log ICE gathering state changes
- Log ICE connection state changes
- Enhanced ICE error logging with details

This will help diagnose connection issues and TURN server problems.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 11:13:24 +01:00
6c2fd7952e Critical fix: Add ICE candidate handlers to service pool
The service pool was creating peer connections but never setting up
onicecandidate handlers. This meant ICE candidates generated by the
TURN relay were never sent to the signaling server, causing all
ICE connectivity checks to fail with no remote candidates.

Changes:
- Add onicecandidate handlers in createOffers() after getting offer IDs
- Add onicecandidate handler in publishInitialService() after publishing
- Handlers send candidates to server via addIceCandidates()

This fixes the "all checks completed success=0 fail=1" error where
remote candidates were empty.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 15:24:09 +01:00
d6f2a10e3e Fix critical bug: track and use data channels from offers
This fixes the root cause of all connection failures. The service pool
was creating data channels but discarding the references, then trying
to wait for a 'datachannel' event that would never fire.

Changes:
- Add dataChannel tracking to OfferPool and ServicePool
- Save data channel references when creating offers
- Pass channels through the answer flow
- Use the existing channel instead of waiting for an event
- Wait for channel.onopen instead of ondatachannel

The offerer (service pool) creates the data channel and must keep that
reference. The 'ondatachannel' event only fires on the answerer side.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 14:24:31 +01:00
a6dc342f3b Fix datachannel handling: auto-create channels from remote peer
Modified peerDataChannelHandler to automatically create DurableChannel
instances when receiving data channels from the remote peer. This fixes
the connection flow where the answerer needs to receive the data channel
that the offerer created.

Previously, the handler only attached if a DurableChannel already existed,
which meant incoming channels from the remote peer would be ignored.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:59:16 +01:00
9486376442 feat: v0.9.0 - durable WebRTC connections with automatic reconnection
Major refactor replacing low-level APIs with high-level durable connections.

New Features:
- Automatic reconnection with exponential backoff (1s → 2s → 4s → ... max 30s)
- Message queuing during disconnections
- Durable channels that survive connection drops
- TTL auto-refresh for services (refreshes at 80% of TTL by default)
- Full configuration of timeouts, retry limits, and queue sizes

New API:
- client.exposeService() - Create durable service with automatic TTL refresh
- client.connect() - Create durable connection with automatic reconnection
- client.connectByUuid() - Connect by service UUID
- DurableChannel - Event-based channel wrapper with message queuing
- DurableConnection - Connection manager with reconnection logic
- DurableService - Service manager with TTL auto-refresh

Files Added:
- src/durable/types.ts - Type definitions and enums
- src/durable/reconnection.ts - Exponential backoff utilities
- src/durable/channel.ts - DurableChannel class (358 lines)
- src/durable/connection.ts - DurableConnection class (441 lines)
- src/durable/service.ts - DurableService class (329 lines)
- MIGRATION.md - Comprehensive migration guide

Files Removed:
- src/services.ts - Replaced by DurableService
- src/discovery.ts - Replaced by DurableConnection

BREAKING CHANGES:
- Removed: client.services.*, client.discovery.*, client.createPeer()
- Added: client.exposeService(), client.connect(), client.connectByUuid()
- Handler signature: (channel, peer, connectionId?) → (channel, connectionId)
- Event handlers: .onmessage → .on('message')
- Services: Must call service.start() to begin accepting connections
- Connections: Must call connection.connect() to establish connection
2025-12-06 13:04:19 +01:00
cffb092d3f Fix WebRTC signaling state error in pooled services
- Add signaling state validation before setting remote answer
- Fix race condition by removing offers from pool before processing
- Add detailed debug logging for state mismatch errors
- Prevent duplicate processing of answered offers

This fixes the "Cannot set remote answer in state stable" error
that occurred when multiple answers arrived in quick succession.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:36:58 +01:00
122f211e7c Fix empty SDP in pooled service offers
The publishInitialService() method was creating an offer with SDP
but not returning it. This caused the first offer in the pool to
have an empty SDP string, which failed when trying to set it as
the local description when an answer arrived.

Fixed by:
- Storing the offer SDP before closing the peer connection
- Adding offerSdp to the return value of publishInitialService()
- Using the returned SDP when creating the initial offer in the pool

This ensures all offers in the pool have valid SDP that can be
used to recreate the peer connection state when answers arrive.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 20:19:14 +01:00
4a6d0ee091 Fix WebRTC state machine error in pooled services
When handling answered offers in pooled services, we were creating fresh
peer connections in "stable" state and trying to set the remote answer,
which caused "Cannot set remote answer in state stable" error.

Fixed by:
- Adding offerSdp to AnsweredOffer interface
- Passing original offer SDP through the offer pool
- Setting local description (offer) before remote description (answer)

This ensures the peer connection is in "have-local-offer" state before
applying the answer, satisfying WebRTC's state machine requirements.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:51:09 +01:00
b2d42fa776 fix: use async ed25519 functions (signAsync, getPublicKeyAsync)
The sync ed25519 functions (sign, getPublicKey) require hashes.sha512,
but WebCrypto only provides async digest. Switch to using the async
ed25519 API which works with hashes.sha512Async.

This fixes the "hashes.sha512 not set" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:19:05 +01:00
63e14ddc5b fix: initialize SHA-512 hash function for @noble/ed25519 v3
@noble/ed25519 v3.0.0 requires explicit SHA-512 hash function setup
before using any cryptographic operations. This fixes the
"hashes.sha512 not set" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:44:03 +01:00
c9f6119148 fix: export V2 API classes and types from index
- Export RondevuUsername, RondevuServices, RondevuDiscovery classes
- Export all related type interfaces
- Export pool-related types (PoolStatus, PooledServiceHandle)

This fixes the issue where the V2 APIs were available as properties
on the Rondevu client instance but not accessible as standalone imports.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:37:33 +01:00
15f821f08a feat: implement offer pooling for multi-connection services
- Add OfferPool class for managing multiple offers with auto-refill polling
- Add ServicePool class for orchestrating pooled connections and connection registry
- Modify exposeService() to support poolSize parameter (backward compatible)
- Add discovery API with service resolution and online status checking
- Add username claiming with Ed25519 signatures and TTL-based expiry
- Fix TypeScript import errors (RondevuPeer default export)
- Fix RondevuPeer instantiation to use RondevuOffers instance
- Fix peer.answer() calls to include required PeerOptions parameter
- Fix Ed25519 API call (randomSecretKey vs randomPrivateKey)
- Remove bloom filter (V1 legacy code)
- Update version to 0.8.0
- Document pooling feature and new APIs in README

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:26:23 +01:00
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
18 changed files with 5510 additions and 1797 deletions

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid"
}

547
MIGRATION.md Normal file
View File

@@ -0,0 +1,547 @@
# Migration Guide: v0.8.x → v0.9.0
This guide helps you migrate from Rondevu Client v0.8.x to v0.9.0.
## Overview
v0.9.0 is a **breaking change** that completely replaces low-level APIs with high-level durable connections featuring automatic reconnection and message queuing.
### What's New
**Durable Connections**: Automatic reconnection on network drops
**Message Queuing**: Messages sent during disconnections are queued and flushed on reconnect
**Durable Channels**: RTCDataChannel wrappers that survive connection drops
**TTL Auto-Refresh**: Services automatically republish before expiration
**Simplified API**: Direct methods on main client instead of nested APIs
### What's Removed
**Low-level APIs**: `client.services.*`, `client.discovery.*`, `client.createPeer()` no longer exported
**Manual Connection Management**: No need to handle WebRTC peer lifecycle manually
**Service Handles**: Replaced with DurableService instances
## Breaking Changes
### 1. Service Exposure
#### v0.8.x (Old)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
const client = new Rondevu();
await client.register();
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
}
});
// Unpublish
await handle.unpublish();
```
#### v0.9.0 (New)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
const client = new Rondevu();
await client.register();
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // NEW: Handle multiple concurrent connections
handler: (channel, connectionId) => {
// NEW: DurableChannel with event emitters
channel.on('message', (data) => {
console.log('Received:', data);
channel.send(`Echo: ${data}`);
});
}
});
// NEW: Start the service
await service.start();
// NEW: Stop the service
await service.stop();
```
**Key Differences:**
- `client.services.exposeService()``client.exposeService()`
- Returns `DurableService` instead of `ServiceHandle`
- Handler receives `DurableChannel` instead of `RTCDataChannel`
- Handler receives `connectionId` string instead of `RondevuPeer`
- DurableChannel uses `.on('message', ...)` instead of `.onmessage = ...`
- Must call `service.start()` to begin accepting connections
- Use `service.stop()` instead of `handle.unpublish()`
### 2. Connecting to Services
#### v0.8.x (Old)
```typescript
// Connect by username + FQN
const { peer, channel } = await client.discovery.connect(
'alice',
'chat@1.0.0'
);
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('connected', () => {
console.log('Connected');
});
peer.on('failed', (error) => {
console.error('Failed:', error);
});
```
#### v0.9.0 (New)
```typescript
// Connect by username + FQN
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 10 // NEW: Configurable reconnection
});
// NEW: Create durable channel
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
channel.on('open', () => {
channel.send('Hello!');
});
// NEW: Connection lifecycle events
connection.on('connected', () => {
console.log('Connected');
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max})...`);
});
connection.on('failed', (error) => {
console.error('Failed permanently:', error);
});
// NEW: Must explicitly connect
await connection.connect();
```
**Key Differences:**
- `client.discovery.connect()``client.connect()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels with `connection.createChannel()`
- Must call `connection.connect()` to establish connection
- Automatic reconnection with configurable retry limits
- Messages sent during disconnection are automatically queued
### 3. Connecting by UUID
#### v0.8.x (Old)
```typescript
const { peer, channel } = await client.discovery.connectByUuid('service-uuid');
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
```
#### v0.9.0 (New)
```typescript
const connection = await client.connectByUuid('service-uuid', {
maxReconnectAttempts: 5
});
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
await connection.connect();
```
**Key Differences:**
- `client.discovery.connectByUuid()``client.connectByUuid()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels and connect explicitly
### 4. Multi-Connection Services (Offer Pooling)
#### v0.8.x (Old)
```typescript
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5,
pollingInterval: 2000,
handler: (channel, peer, connectionId) => {
console.log(`Connection: ${connectionId}`);
},
onPoolStatus: (status) => {
console.log('Pool status:', status);
}
});
const status = handle.getStatus();
await handle.addOffers(3);
```
#### v0.9.0 (New)
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5, // SAME: Pool size
pollingInterval: 2000, // SAME: Polling interval
handler: (channel, connectionId) => {
console.log(`Connection: ${connectionId}`);
}
});
await service.start();
// Get active connections
const connections = service.getActiveConnections();
// Listen for connection events
service.on('connection', (connectionId) => {
console.log('New connection:', connectionId);
});
```
**Key Differences:**
- `onPoolStatus` callback removed (use `service.on('connection')` instead)
- `handle.getStatus()` replaced with `service.getActiveConnections()`
- `handle.addOffers()` removed (pool auto-manages offers)
- Handler receives `DurableChannel` instead of `RTCDataChannel`
## Feature Comparison
| Feature | v0.8.x | v0.9.0 |
|---------|--------|--------|
| Service exposure | `client.services.exposeService()` | `client.exposeService()` |
| Connection | `client.discovery.connect()` | `client.connect()` |
| Connection by UUID | `client.discovery.connectByUuid()` | `client.connectByUuid()` |
| Channel type | `RTCDataChannel` | `DurableChannel` |
| Event handling | `.onmessage`, `.onopen`, etc. | `.on('message')`, `.on('open')`, etc. |
| Automatic reconnection | ❌ No | ✅ Yes (configurable) |
| Message queuing | ❌ No | ✅ Yes (during disconnections) |
| TTL auto-refresh | ❌ No | ✅ Yes (configurable) |
| Peer lifecycle | Manual | Automatic |
| Connection pooling | ✅ Yes | ✅ Yes (same API) |
## API Mapping
### Removed Exports
These are no longer exported in v0.9.0:
```typescript
// ❌ Removed
import {
RondevuServices,
RondevuDiscovery,
RondevuPeer,
ServiceHandle,
PooledServiceHandle,
ConnectResult
} from '@xtr-dev/rondevu-client';
```
### New Exports
These are new in v0.9.0:
```typescript
// ✅ New
import {
DurableConnection,
DurableChannel,
DurableService,
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo,
QueuedMessage
} from '@xtr-dev/rondevu-client';
```
### Unchanged Exports
These work the same in both versions:
```typescript
// ✅ Unchanged
import {
Rondevu,
RondevuAuth,
RondevuUsername,
Credentials,
UsernameClaimResult,
UsernameCheckResult
} from '@xtr-dev/rondevu-client';
```
## Configuration Options
### New Connection Options
v0.9.0 adds extensive configuration for automatic reconnection and message queuing:
```typescript
const connection = await client.connect('alice', 'chat@1.0.0', {
// Reconnection
maxReconnectAttempts: 10, // default: 10
reconnectBackoffBase: 1000, // default: 1000ms
reconnectBackoffMax: 30000, // default: 30000ms (30 seconds)
reconnectJitter: 0.2, // default: 0.2 (±20%)
connectionTimeout: 30000, // default: 30000ms
// Message queuing
maxQueueSize: 1000, // default: 1000 messages
maxMessageAge: 60000, // default: 60000ms (1 minute)
// WebRTC
rtcConfig: {
iceServers: [...]
}
});
```
### New Service Options
Services can now auto-refresh TTL:
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
// TTL auto-refresh (NEW)
ttl: 300000, // default: 300000ms (5 minutes)
ttlRefreshMargin: 0.2, // default: 0.2 (refresh at 80% of TTL)
// All connection options also apply to incoming connections
maxReconnectAttempts: 10,
maxQueueSize: 1000,
// ...
});
```
## Migration Checklist
- [ ] Replace `client.services.exposeService()` with `client.exposeService()`
- [ ] Add `await service.start()` after creating service
- [ ] Replace `handle.unpublish()` with `service.stop()`
- [ ] Replace `client.discovery.connect()` with `client.connect()`
- [ ] Replace `client.discovery.connectByUuid()` with `client.connectByUuid()`
- [ ] Create channels with `connection.createChannel()` instead of receiving them directly
- [ ] Add `await connection.connect()` to establish connection
- [ ] Update handlers from `(channel, peer, connectionId?)` to `(channel, connectionId)`
- [ ] Replace `.onmessage` with `.on('message', ...)`
- [ ] Replace `.onopen` with `.on('open', ...)`
- [ ] Replace `.onclose` with `.on('close', ...)`
- [ ] Replace `.onerror` with `.on('error', ...)`
- [ ] Add reconnection event handlers (`connection.on('reconnecting', ...)`)
- [ ] Review and configure reconnection options if needed
- [ ] Review and configure message queue limits if needed
- [ ] Update TypeScript imports to use new types
- [ ] Test automatic reconnection behavior
- [ ] Test message queuing during disconnections
## Common Migration Patterns
### Pattern 1: Simple Echo Service
#### Before (v0.8.x)
```typescript
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'echo@1.0.0',
handler: (channel) => {
channel.onmessage = (e) => {
channel.send(`Echo: ${e.data}`);
};
}
});
```
#### After (v0.9.0)
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'echo@1.0.0',
handler: (channel) => {
channel.on('message', (data) => {
channel.send(`Echo: ${data}`);
});
}
});
await service.start();
```
### Pattern 2: Connection with Error Handling
#### Before (v0.8.x)
```typescript
try {
const { peer, channel } = await client.discovery.connect('alice', 'chat@1.0.0');
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('failed', (error) => {
console.error('Connection failed:', error);
// Manual reconnection logic here
});
} catch (error) {
console.error('Failed to connect:', error);
}
```
#### After (v0.9.0)
```typescript
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 5
});
const channel = connection.createChannel('main');
channel.on('open', () => {
channel.send('Hello!');
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max}) in ${delay}ms`);
});
connection.on('failed', (error) => {
console.error('Connection failed permanently:', error);
});
try {
await connection.connect();
} catch (error) {
console.error('Initial connection failed:', error);
}
```
### Pattern 3: Multi-User Chat Server
#### Before (v0.8.x)
```typescript
const connections = new Map();
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, peer, connectionId) => {
connections.set(connectionId, channel);
channel.onmessage = (e) => {
// Broadcast to all
for (const [id, ch] of connections) {
if (id !== connectionId) {
ch.send(e.data);
}
}
};
channel.onclose = () => {
connections.delete(connectionId);
};
}
});
```
#### After (v0.9.0)
```typescript
const channels = new Map();
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, connectionId) => {
channels.set(connectionId, channel);
channel.on('message', (data) => {
// Broadcast to all
for (const [id, ch] of channels) {
if (id !== connectionId) {
ch.send(data);
}
}
});
channel.on('close', () => {
channels.delete(connectionId);
});
}
});
await service.start();
// Optional: Track connections
service.on('connection', (connectionId) => {
console.log(`User ${connectionId} joined`);
});
service.on('disconnection', (connectionId) => {
console.log(`User ${connectionId} left`);
});
```
## Benefits of Migration
1. **Reliability**: Automatic reconnection handles network hiccups transparently
2. **Simplicity**: No need to manage WebRTC peer lifecycle manually
3. **Durability**: Messages sent during disconnections are queued and delivered when connection restores
4. **Uptime**: Services automatically refresh TTL before expiration
5. **Type Safety**: Better TypeScript types with DurableChannel event emitters
6. **Debugging**: Queue size monitoring, connection state tracking, and detailed events
## Getting Help
If you encounter issues during migration:
1. Check the [README](./README.md) for complete API documentation
2. Review the examples for common patterns
3. Open an issue on [GitHub](https://github.com/xtr-dev/rondevu-client/issues)

701
README.md
View File

@@ -1,25 +1,30 @@
# @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)
🌐 **Topic-based peer discovery and WebRTC signaling client**
🌐 **Simple, high-level WebRTC peer-to-peer connections**
TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling.
TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connections with automatic signaling, username-based discovery, and built-in reconnection support.
**Related repositories:**
- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server
- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo
- [@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
- **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
- **High-Level Wrappers**: ServiceHost and ServiceClient eliminate WebRTC boilerplate
- **Username-Based Discovery**: Connect to peers by username, not complex offer/answer exchange
- **Semver-Compatible Matching**: Requesting chat@1.0.0 matches any compatible 1.x.x version
- **Privacy-First Design**: Services are hidden by default - no enumeration possible
- **Automatic Reconnection**: Built-in retry logic with exponential backoff
- **Message Queuing**: Messages sent while disconnected are queued and flushed on reconnect
- **Cryptographic Username Claiming**: Secure ownership with Ed25519 signatures
- **Service Publishing**: Package-style naming (chat.app@1.0.0) with multiple simultaneous offers
- **TypeScript**: Full type safety and autocomplete
- **Configurable Polling**: Exponential backoff with jitter to reduce server load
## Install
@@ -29,376 +34,400 @@ npm install @xtr-dev/rondevu-client
## Quick Start
The easiest way to use Rondevu is with the high-level `RondevuConnection` class, which handles all WebRTC connection complexity including offer/answer exchange, ICE candidates, and connection lifecycle.
### Creating an Offer (Peer A)
### Hosting a Service (Alice)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { RondevuService, ServiceHost } from '@xtr-dev/rondevu-client'
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Step 1: Create and initialize service
const service = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
// Create a connection
const conn = client.createConnection();
await service.initialize() // Generates keypair
await service.claimUsername() // Claims username with signature
// Set up event listeners
conn.on('connected', () => {
console.log('Connected to peer!');
});
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
channel.send('Hello from peer A!');
});
// Create offer and advertise on topics
const offerId = await conn.createOffer({
topics: ['my-app', 'room-123'],
// Step 2: Create ServiceHost
const host = new ServiceHost({
service: 'chat.app@1.0.0',
rondevuService: service,
maxPeers: 5, // Accept up to 5 connections
ttl: 300000 // 5 minutes
});
})
console.log('Offer created:', offerId);
console.log('Share these topics with peers:', ['my-app', 'room-123']);
// Step 3: Listen for incoming connections
host.events.on('connection', (connection) => {
console.log('✅ New connection!')
connection.events.on('message', (msg) => {
console.log('📨 Received:', msg)
connection.sendMessage('Hello from Alice!')
})
connection.events.on('state-change', (state) => {
console.log('Connection state:', state)
})
})
host.events.on('error', (error) => {
console.error('Host error:', error)
})
// Step 4: Start hosting
await host.start()
console.log('Service is now live! Others can connect to @alice')
// Later: stop hosting
host.dispose()
```
### Answering an Offer (Peer B)
### Connecting to a Service (Bob)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { RondevuService, ServiceClient } from '@xtr-dev/rondevu-client'
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Step 1: Create and initialize service
const service = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: 'bob'
})
// Discover offers by topic
const offers = await client.offers.findByTopic('my-app', { limit: 10 });
await service.initialize()
await service.claimUsername()
if (offers.length > 0) {
const offer = offers[0];
// Step 2: Create ServiceClient
const client = new ServiceClient({
username: 'alice', // Connect to Alice
serviceFqn: 'chat.app@1.0.0',
rondevuService: service,
autoReconnect: true,
maxReconnectAttempts: 5
})
// Create connection
const conn = client.createConnection();
// Step 3: Listen for connection events
client.events.on('connected', (connection) => {
console.log('✅ Connected to Alice!')
// Set up event listeners
conn.on('connecting', () => {
console.log('Connecting...');
});
connection.events.on('message', (msg) => {
console.log('📨 Received:', msg)
})
conn.on('connected', () => {
console.log('Connected!');
});
// Send a message
connection.sendMessage('Hello from Bob!')
})
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
client.events.on('disconnected', () => {
console.log('🔌 Disconnected')
})
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
client.events.on('reconnecting', ({ attempt, maxAttempts }) => {
console.log(`🔄 Reconnecting (${attempt}/${maxAttempts})...`)
})
channel.send('Hello from peer B!');
});
client.events.on('error', (error) => {
console.error('❌ Error:', error)
})
// Answer the offer
await conn.answer(offer.id, offer.sdp);
}
// Step 4: Connect
await client.connect()
// Later: disconnect
client.dispose()
```
### Connection Events
## Core Concepts
```typescript
conn.on('connecting', () => {
// Connection is being established
});
### RondevuService
conn.on('connected', () => {
// Connection established successfully
});
Handles authentication and username management:
- Generates Ed25519 keypair for signing
- Claims usernames with cryptographic proof
- Provides API client for signaling server
conn.on('disconnected', () => {
// Connection lost or closed
});
### ServiceHost
conn.on('error', (error) => {
// An error occurred
console.error('Connection error:', error);
});
High-level wrapper for hosting a WebRTC service:
- Automatically creates and publishes offers
- Handles incoming connections
- Manages ICE candidate exchange
- Supports multiple simultaneous peers
conn.on('datachannel', (channel) => {
// Data channel is ready to use
});
### ServiceClient
conn.on('track', (event) => {
// Media track received (for audio/video streaming)
const stream = event.streams[0];
videoElement.srcObject = stream;
});
```
High-level wrapper for connecting to services:
- Discovers services by username
- Handles offer/answer exchange automatically
- Built-in auto-reconnection with exponential backoff
- Event-driven API
### Adding Media Tracks
### RTCDurableConnection
```typescript
// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// Add tracks to connection
stream.getTracks().forEach(track => {
conn.addTrack(track, stream);
});
```
### Connection Properties
```typescript
// Get connection state
console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc.
// Get offer ID
console.log(conn.id);
// Get data channel
console.log(conn.channel);
```
### Closing a Connection
```typescript
conn.close();
```
## Platform-Specific Setup
### Node.js 18+ (with native fetch)
Works out of the box - no additional setup needed.
### Node.js < 18 (without native fetch)
Install node-fetch and provide it to the client:
```bash
npm install node-fetch
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any
});
```
### Deno
```typescript
import { Rondevu } from 'npm:@xtr-dev/rondevu-client';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
```
### Bun
Works out of the box - no additional setup needed.
### Cloudflare Workers
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
export default {
async fetch(request: Request, env: Env) {
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
const creds = await client.register();
return new Response(JSON.stringify(creds));
}
};
```
## Low-Level API Usage
For advanced use cases where you need direct control over the signaling process, you can use the low-level API:
```typescript
import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
// Register and get credentials
const creds = await client.register();
console.log('Peer ID:', creds.peerId);
// Save credentials for later use
localStorage.setItem('rondevu-creds', JSON.stringify(creds));
// Create offer with topics
const offers = await client.offers.create([{
sdp: 'v=0...', // Your WebRTC offer SDP
topics: ['movie-xyz', 'hd-content'],
ttl: 300000 // 5 minutes
}]);
// Discover peers by topic
const discovered = await client.offers.findByTopic('movie-xyz', {
limit: 50
});
console.log(`Found ${discovered.length} peers`);
// Use bloom filter to exclude known peers
const knownPeers = new Set(['peer-id-1', 'peer-id-2']);
const bloom = new BloomFilter(1024, 3);
knownPeers.forEach(id => bloom.add(id));
const newPeers = await client.offers.findByTopic('movie-xyz', {
bloomFilter: bloom.toBytes(),
limit: 50
});
```
Low-level connection wrapper (used internally):
- Manages WebRTC PeerConnection lifecycle
- Handles ICE candidate polling
- Provides message queue for reliability
- State management and events
## API Reference
### Authentication
#### `client.register()`
Register a new peer and receive credentials.
### RondevuService
```typescript
const creds = await client.register();
// { peerId: '...', secret: '...' }
const service = new RondevuService({
apiUrl: string, // Signaling server URL
username: string, // Your username
keypair?: Keypair // Optional: reuse existing keypair
})
// Initialize service (generates keypair if not provided)
await service.initialize(): Promise<void>
// Claim username with cryptographic signature
await service.claimUsername(): Promise<void>
// Check if username is claimed
service.isUsernameClaimed(): boolean
// Get current username
service.getUsername(): string
// Get keypair
service.getKeypair(): Keypair
// Get API client
service.getAPI(): RondevuAPI
```
### Topics
#### `client.offers.getTopics(options?)`
List all topics with active peer counts (paginated).
### ServiceHost
```typescript
const result = await client.offers.getTopics({
limit: 50,
offset: 0
});
const host = new ServiceHost({
service: string, // Service FQN (e.g., 'chat.app@1.0.0')
rondevuService: RondevuService,
maxPeers?: number, // Default: 5
ttl?: number, // Default: 300000 (5 minutes)
isPublic?: boolean, // Default: true
rtcConfiguration?: RTCConfiguration
})
// {
// topics: [
// { topic: 'movie-xyz', activePeers: 42 },
// { topic: 'torrent-abc', activePeers: 15 }
// ],
// total: 123,
// limit: 50,
// offset: 0
// }
// Start hosting
await host.start(): Promise<void>
// Stop hosting and cleanup
host.dispose(): void
// Get all active connections
host.getConnections(): RTCDurableConnection[]
// Events
host.events.on('connection', (conn: RTCDurableConnection) => {})
host.events.on('error', (error: Error) => {})
```
### Offers
#### `client.offers.create(offers)`
Create one or more offers with topics.
### ServiceClient
```typescript
const offers = await client.offers.create([
const client = new ServiceClient({
username: string, // Host username to connect to
serviceFqn: string, // Service FQN (e.g., 'chat.app@1.0.0')
rondevuService: RondevuService,
autoReconnect?: boolean, // Default: true
maxReconnectAttempts?: number, // Default: 5
rtcConfiguration?: RTCConfiguration
})
// Connect to service
await client.connect(): Promise<RTCDurableConnection>
// Disconnect and cleanup
client.dispose(): void
// Get current connection
client.getConnection(): RTCDurableConnection | null
// Events
client.events.on('connected', (conn: RTCDurableConnection) => {})
client.events.on('disconnected', () => {})
client.events.on('reconnecting', (info: { attempt: number, maxAttempts: number }) => {})
client.events.on('error', (error: Error) => {})
```
### RTCDurableConnection
```typescript
// Connection state
connection.state: 'connected' | 'connecting' | 'disconnected'
// Send message (returns true if sent, false if queued)
await connection.sendMessage(message: string): Promise<boolean>
// Queue message for sending when connected
await connection.queueMessage(message: string, options?: QueueMessageOptions): Promise<void>
// Disconnect
connection.disconnect(): void
// Events
connection.events.on('message', (msg: string) => {})
connection.events.on('state-change', (state: ConnectionStates) => {})
```
## Configuration
### Polling Configuration
The signaling uses configurable polling with exponential backoff:
```typescript
// Default polling config
{
sdp: 'v=0...',
topics: ['topic-1', 'topic-2'],
ttl: 300000 // optional, default 5 minutes
initialInterval: 500, // Start at 500ms
maxInterval: 5000, // Max 5 seconds
backoffMultiplier: 1.5, // Increase by 1.5x each time
maxRetries: 50, // Max 50 attempts
jitter: true // Add random 0-100ms to prevent thundering herd
}
]);
```
#### `client.offers.findByTopic(topic, options?)`
Find offers by topic with optional bloom filter.
This is handled automatically - no configuration needed.
### WebRTC Configuration
Provide custom STUN/TURN servers:
```typescript
const offers = await client.offers.findByTopic('movie-xyz', {
limit: 50,
bloomFilter: bloomBytes // optional
});
const host = new ServiceHost({
service: 'chat.app@1.0.0',
rondevuService: service,
rtcConfiguration: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
]
}
})
```
#### `client.offers.getMine()`
Get all offers owned by the authenticated peer.
## Username Rules
- **Format**: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`)
- **Length**: 3-32 characters
- **Pattern**: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`
- **Validity**: 365 days from claim/last use
- **Ownership**: Secured by Ed25519 public key signature
## Examples
### Chat Application
See [demo/demo.js](./demo/demo.js) for a complete working example.
### Persistent Keypair
```typescript
const myOffers = await client.offers.getMine();
// Save keypair to localStorage
const service = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
await service.initialize()
await service.claimUsername()
// Save for later
localStorage.setItem('rondevu-keypair', JSON.stringify(service.getKeypair()))
localStorage.setItem('rondevu-username', service.getUsername())
// Load on next session
const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair'))
const savedUsername = localStorage.getItem('rondevu-username')
const service2 = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: savedUsername,
keypair: savedKeypair
})
await service2.initialize() // Reuses keypair
```
#### `client.offers.heartbeat(offerId)`
Update last_seen timestamp for an offer.
### Message Queue Example
```typescript
await client.offers.heartbeat(offerId);
// Messages are automatically queued if not connected yet
client.events.on('connected', (connection) => {
// Send immediately
connection.sendMessage('Hello!')
})
// Or queue for later
await client.connect()
const conn = client.getConnection()
await conn.queueMessage('This will be sent when connected', {
expiresAt: Date.now() + 60000 // Expire after 1 minute
})
```
#### `client.offers.delete(offerId)`
Delete a specific offer.
## Migration from v0.9.x
v0.11.0+ introduces high-level wrappers, RESTful API changes, and semver-compatible discovery:
**API Changes:**
- Server endpoints restructured (`/usernames/*``/users/*`)
- Added `ServiceHost` and `ServiceClient` wrappers
- Message queue fully implemented
- Configurable polling with exponential backoff
- Removed deprecated `cleanup()` methods (use `dispose()`)
- **v0.11.0+**: Services use `offers` array instead of single `sdp`
- **v0.11.0+**: Semver-compatible service discovery (chat@1.0.0 matches 1.x.x)
- **v0.11.0+**: All services are hidden - no listing endpoint
- **v0.11.0+**: Services support multiple simultaneous offers for connection pooling
**Migration Guide:**
```typescript
await client.offers.delete(offerId);
// Before (v0.9.x) - Manual WebRTC setup
const signaler = new RondevuSignaler(service, 'chat@1.0.0')
const context = new WebRTCContext()
const pc = context.createPeerConnection()
// ... 50+ lines of boilerplate
// After (v0.11.0) - ServiceHost wrapper
const host = new ServiceHost({
service: 'chat@1.0.0',
rondevuService: service
})
await host.start()
// Done!
```
#### `client.offers.answer(offerId, sdp)`
Answer an offer (locks it to answerer).
## Platform Support
```typescript
await client.offers.answer(offerId, answerSdp);
### Modern Browsers
Works out of the box - no additional setup needed.
### Node.js 18+
Native fetch is available, but WebRTC requires polyfills:
```bash
npm install wrtc
```
#### `client.offers.getAnswers()`
Poll for answers to your offers.
```typescript
const answers = await client.offers.getAnswers();
```
import { WebRTCContext } from '@xtr-dev/rondevu-client'
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'
### ICE Candidates
#### `client.offers.addIceCandidates(offerId, candidates)`
Post ICE candidates for an offer.
```typescript
await client.offers.addIceCandidates(offerId, [
'candidate:1 1 UDP...'
]);
```
#### `client.offers.getIceCandidates(offerId, since?)`
Get ICE candidates from the other peer.
```typescript
const candidates = await client.offers.getIceCandidates(offerId);
```
### Bloom Filter
```typescript
import { BloomFilter } from '@xtr-dev/rondevu-client';
// Create filter: size=1024 bits, hash=3 functions
const bloom = new BloomFilter(1024, 3);
// Add items
bloom.add('peer-id-1');
bloom.add('peer-id-2');
// Test membership
bloom.test('peer-id-1'); // true (probably)
bloom.test('unknown'); // false (definitely)
// Export for API
const bytes = bloom.toBytes();
// Configure WebRTC context
const context = new WebRTCContext({
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
} as any)
```
## TypeScript
@@ -407,45 +436,21 @@ All types are exported:
```typescript
import type {
RondevuServiceOptions,
ServiceHostOptions,
ServiceHostEvents,
ServiceClientOptions,
ServiceClientEvents,
ConnectionInterface,
ConnectionEvents,
ConnectionStates,
Message,
QueueMessageOptions,
Signaler,
PollingConfig,
Credentials,
Offer,
CreateOfferRequest,
TopicInfo,
IceCandidate,
FetchFunction,
RondevuOptions,
ConnectionOptions,
RondevuConnectionEvents
} from '@xtr-dev/rondevu-client';
```
## Environment Compatibility
The client library is designed to work across different JavaScript runtimes:
| Environment | Native Fetch | Custom Fetch Needed |
|-------------|--------------|---------------------|
| Modern Browsers | ✅ Yes | ❌ No |
| Node.js 18+ | ✅ Yes | ❌ No |
| Node.js < 18 | ❌ No | ✅ Yes (node-fetch) |
| Deno | ✅ Yes | ❌ No |
| Bun | ✅ Yes | ❌ No |
| Cloudflare Workers | ✅ Yes | ❌ No |
**If your environment doesn't have native fetch:**
```bash
npm install node-fetch
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
fetch: fetch as any
});
Keypair
} from '@xtr-dev/rondevu-client'
```
## License

281
USAGE.md Normal file
View File

@@ -0,0 +1,281 @@
# Rondevu Client Usage Guide
## Installation
```bash
npm install @xtr-dev/rondevu-client
```
## Quick Start
### 1. Register and Create Connection
```typescript
import { RondevuAPI, RondevuSignaler, WebRTCRondevuConnection } from '@xtr-dev/rondevu-client';
const API_URL = 'https://api.ronde.vu';
// Register to get credentials
const api = new RondevuAPI(API_URL);
const credentials = await api.register();
// Create authenticated API client
const authenticatedApi = new RondevuAPI(API_URL, credentials);
```
### 2. Create an Offer (Offerer Side)
```typescript
// Create a connection
const connection = new WebRTCRondevuConnection(
'connection-id',
'host-username',
'service-name'
);
// Wait for local description
await connection.ready;
// Create offer on server
const offers = await authenticatedApi.createOffers([{
sdp: connection.connection.localDescription!.sdp!,
ttl: 300000 // 5 minutes
}]);
const offerId = offers[0].id;
// Set up signaler for ICE candidate exchange
const signaler = new RondevuSignaler(authenticatedApi, offerId);
connection.setSignaler(signaler);
// Poll for answer
const checkAnswer = setInterval(async () => {
const answer = await authenticatedApi.getAnswer(offerId);
if (answer) {
clearInterval(checkAnswer);
await connection.connection.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
console.log('Connection established!');
}
}, 1000);
```
### 3. Answer an Offer (Answerer Side)
```typescript
// Get the offer
const offer = await authenticatedApi.getOffer(offerId);
// Create connection with remote offer
const connection = new WebRTCRondevuConnection(
'connection-id',
'peer-username',
'service-name',
{
type: 'offer',
sdp: offer.sdp
}
);
// Wait for local description (answer)
await connection.ready;
// Send answer to server
await authenticatedApi.answerOffer(
offerId,
connection.connection.localDescription!.sdp!
);
// Set up signaler for ICE candidate exchange
const signaler = new RondevuSignaler(authenticatedApi, offerId);
connection.setSignaler(signaler);
console.log('Connection established!');
```
## Using Services
### Publish a Service
```typescript
import { RondevuAPI } from '@xtr-dev/rondevu-client';
const api = new RondevuAPI(API_URL, credentials);
const service = await api.publishService({
username: 'my-username',
serviceFqn: 'chat.app@1.0.0',
sdp: localDescription.sdp,
ttl: 300000,
isPublic: true,
metadata: { description: 'My chat service' },
signature: '...', // Ed25519 signature
message: '...' // Signed message
});
console.log('Service UUID:', service.uuid);
```
### Connect to a Service
```typescript
// Search for services
const services = await api.searchServices('username', 'chat.app@1.0.0');
if (services.length > 0) {
// Get service details with offer
const service = await api.getService(services[0].uuid);
// Create connection with service offer
const connection = new WebRTCRondevuConnection(
service.serviceId,
service.username,
service.serviceFqn,
{
type: 'offer',
sdp: service.sdp
}
);
await connection.ready;
// Answer the service offer
await api.answerOffer(
service.offerId,
connection.connection.localDescription!.sdp!
);
// Set up signaler
const signaler = new RondevuSignaler(api, service.offerId);
connection.setSignaler(signaler);
}
```
## Event Handling
```typescript
import { EventBus } from '@xtr-dev/rondevu-client';
// Connection events
connection.events.on('state-change', (state) => {
console.log('Connection state:', state);
});
connection.events.on('message', (message) => {
console.log('Received message:', message);
});
// Custom events with EventBus
interface MyEvents {
'user:connected': { userId: string; timestamp: number };
'message:sent': string;
}
const events = new EventBus<MyEvents>();
events.on('user:connected', (data) => {
console.log(`User ${data.userId} connected at ${data.timestamp}`);
});
events.emit('user:connected', {
userId: '123',
timestamp: Date.now()
});
```
## Cleanup
```typescript
import { createBin } from '@xtr-dev/rondevu-client';
const bin = createBin();
// Add cleanup functions
bin(
() => console.log('Cleanup 1'),
() => console.log('Cleanup 2')
);
// Clean all
bin.clean();
```
## API Reference
### RondevuAPI
Complete API client for Rondevu signaling server.
**Methods:**
- `register()` - Register new peer
- `createOffers(offers)` - Create offers
- `getOffer(offerId)` - Get offer by ID
- `answerOffer(offerId, sdp)` - Answer an offer
- `getAnswer(offerId)` - Poll for answer
- `searchOffers(topic)` - Search by topic
- `addIceCandidates(offerId, candidates)` - Add ICE candidates
- `getIceCandidates(offerId, since)` - Get ICE candidates (polling)
- `publishService(service)` - Publish service
- `getService(uuid)` - Get service by UUID
- `searchServices(username, serviceFqn)` - Search services
- `checkUsername(username)` - Check availability
- `claimUsername(username, publicKey, signature, message)` - Claim username
### RondevuSignaler
Handles ICE candidate exchange via polling.
**Constructor:**
```typescript
new RondevuSignaler(api: RondevuAPI, offerId: string)
```
**Methods:**
- `addIceCandidate(candidate)` - Send local candidate
- `addListener(callback)` - Poll for remote candidates (returns cleanup function)
### WebRTCRondevuConnection
WebRTC connection wrapper with type-safe events.
**Constructor:**
```typescript
new WebRTCRondevuConnection(
id: string,
host: string,
service: string,
offer?: RTCSessionDescriptionInit
)
```
**Properties:**
- `id` - Connection ID
- `host` - Host username
- `service` - Service FQN
- `state` - Connection state
- `events` - EventBus for state changes and messages
- `ready` - Promise that resolves when local description is set
**Methods:**
- `setSignaler(signaler)` - Set signaler for ICE exchange
- `queueMessage(message, options)` - Queue message for sending
- `sendMessage(message)` - Send message immediately
### EventBus<TEvents>
Type-safe event emitter with inferred types.
**Methods:**
- `on(event, handler)` - Subscribe
- `once(event, handler)` - Subscribe once
- `off(event, handler)` - Unsubscribe
- `emit(event, data)` - Emit event
- `clear(event?)` - Clear handlers
- `listenerCount(event)` - Get listener count
- `eventNames()` - Get event names
## Examples
See the demo application at https://github.com/xtr-dev/rondevu-demo for a complete working example.

52
eslint.config.js Normal file
View File

@@ -0,0 +1,52 @@
import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'
import unicorn from 'eslint-plugin-unicorn'
import globals from 'globals'
export default [
js.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx', '**/*.js'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
...globals.browser,
...globals.node,
RTCPeerConnection: 'readonly',
RTCIceCandidate: 'readonly',
RTCSessionDescriptionInit: 'readonly',
RTCIceCandidateInit: 'readonly',
BufferSource: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
prettier: prettierPlugin,
unicorn: unicorn,
},
rules: {
...tsPlugin.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'unicorn/filename-case': [
'error',
{
case: 'kebabCase',
ignore: ['^README\\.md$'],
},
],
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
},
]

2997
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,17 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.4.1",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
"version": "0.13.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "vite",
"lint": "eslint src demo --ext .ts,.tsx,.js",
"lint:fix": "eslint src demo --ext .ts,.tsx,.js --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js}\" \"demo/**/*.{ts,tsx,js,html}\"",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -20,10 +24,23 @@
"author": "",
"license": "MIT",
"devDependencies": {
"typescript": "^5.9.3"
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"files": [
"dist",
"README.md"
]
],
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
}

458
src/api.ts Normal file
View File

@@ -0,0 +1,458 @@
/**
* Rondevu API Client - Single class for all API endpoints
*/
import * as ed25519 from '@noble/ed25519'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
}
export interface Credentials {
peerId: string
secret: string
}
export interface Keypair {
publicKey: string
privateKey: string
}
export interface OfferRequest {
sdp: string
topics?: string[]
ttl?: number
secret?: string
}
export interface Offer {
id: string
peerId: string
sdp: string
topics: string[]
ttl: number
createdAt: number
expiresAt: number
answererPeerId?: string
}
export interface OfferRequest {
sdp: string
}
export interface ServiceRequest {
serviceFqn: string // Must include username: service:version@username
offers: OfferRequest[]
ttl?: number
signature: string
message: string
}
export interface ServiceOffer {
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface Service {
serviceId: string
offers: ServiceOffer[]
username: string
serviceFqn: string
createdAt: number
expiresAt: number
}
export interface IceCandidate {
candidate: RTCIceCandidateInit
createdAt: number
}
/**
* Helper: Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
}
/**
* Helper: Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
}
/**
* RondevuAPI - Complete API client for Rondevu signaling server
*/
export class RondevuAPI {
constructor(
private baseUrl: string,
private credentials?: Credentials
) {}
/**
* Set credentials for authentication
*/
setCredentials(credentials: Credentials): void {
this.credentials = credentials
}
/**
* Authentication header
*/
private getAuthHeader(): Record<string, string> {
if (!this.credentials) {
return {}
}
return {
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
}
}
// ============================================
// Ed25519 Cryptography Helpers
// ============================================
/**
* Generate an Ed25519 keypair for username claiming and service publishing
*/
static async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
}
/**
* Sign a message with an Ed25519 private key
*/
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return bytesToBase64(signature)
}
/**
* Verify a signature
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64)
const signature = base64ToBytes(signatureBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
}
// ============================================
// Authentication
// ============================================
/**
* Register a new peer and get credentials
*/
async register(): Promise<Credentials> {
const response = await fetch(`${this.baseUrl}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Registration failed: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// Offers
// ============================================
/**
* Create one or more offers
*/
async createOffers(offers: OfferRequest[]): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
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}`)
}
return await response.json()
}
/**
* Get offer by ID
*/
async getOffer(offerId: string): Promise<Offer> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Answer a specific offer from a service
*/
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ sdp }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get answer for a specific offer (offerer polls this)
*/
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
// 404 means not yet answered
if (response.status === 404) {
return null
}
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Search offers by topic
*/
async searchOffers(topic: string): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// ICE Candidates
// ============================================
/**
* Add ICE candidates to a specific offer
*/
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
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}`)
}
return await response.json()
}
/**
* Get ICE candidates for a specific offer (with polling support)
*/
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`)
url.searchParams.set('since', since.toString())
const response = await fetch(url.toString(), { headers: this.getAuthHeader() })
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 {
candidates: data.candidates || [],
offerId: data.offerId
}
}
// ============================================
// Services
// ============================================
/**
* Publish a service
* Service FQN must include username: service:version@username
*/
async publishService(service: ServiceRequest): Promise<Service> {
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify(service),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to discover service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`)
url.searchParams.set('limit', limit.toString())
url.searchParams.set('offset', offset.toString())
const response = await fetch(url.toString(), {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to discover services: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// Usernames
// ============================================
/**
* Check if username is available
*/
async checkUsername(username: string): Promise<{ available: boolean; publicKey?: string; claimedAt?: number; expiresAt?: number }> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}`
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Claim a username (requires Ed25519 signature)
*/
async claimUsername(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ success: boolean; username: string }> {
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({
publicKey,
signature,
message,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
}
return await response.json()
}
}

View File

@@ -1,60 +0,0 @@
export interface Credentials {
peerId: string;
secret: string;
}
// Fetch-compatible function type
export type FetchFunction = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Response>;
export class RondevuAuth {
private fetchFn: FetchFunction;
constructor(
private baseUrl: string,
fetchFn?: FetchFunction
) {
// Use provided fetch or fall back to global fetch
this.fetchFn = fetchFn || ((...args) => {
if (typeof globalThis.fetch === 'function') {
return globalThis.fetch(...args);
}
throw new Error(
'fetch is not available. Please provide a fetch implementation in the constructor options.'
);
});
}
/**
* Register a new peer and receive credentials
*/
async register(): Promise<Credentials> {
const response = await this.fetchFn(`${this.baseUrl}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Registration failed: ${error.error || response.statusText}`);
}
const data = await response.json();
return {
peerId: data.peerId,
secret: data.secret,
};
}
/**
* Create Authorization header value
*/
static createAuthHeader(credentials: Credentials): string {
return `Bearer ${credentials.peerId}:${credentials.secret}`;
}
}

View File

@@ -1,83 +0,0 @@
// 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,208 +0,0 @@
import {
RondevuClientOptions,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
VersionResponse,
HealthResponse,
ErrorResponse,
Side,
} from './types.js';
/**
* HTTP API client for Rondevu peer signaling server
*/
export class RondevuAPI {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu API client instance
* @param options - Client configuration options
*/
constructor(options: RondevuClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis);
}
/**
* Makes an HTTP request to the Rondevu server
*/
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await this.fetchImpl(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
const error = data as ErrorResponse;
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return data as T;
}
/**
* Gets server version information
*
* @returns Server version
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { version } = await api.getVersion();
* console.log('Server version:', version);
* ```
*/
async getVersion(): Promise<VersionResponse> {
return this.request<VersionResponse>('/', {
method: 'GET',
});
}
/**
* Creates a new offer
*
* @param request - Offer details including peer ID, signaling data, and optional custom code
* @returns Unique offer code (UUID or custom code)
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { code } = await api.createOffer({
* peerId: 'peer-123',
* offer: signalingData,
* code: 'my-custom-code' // optional
* });
* console.log('Offer code:', code);
* ```
*/
async createOffer(request: CreateOfferRequest): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>('/offer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Sends an answer or candidate to an existing offer
*
* @param request - Answer details including offer code and signaling data
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Send answer
* await api.sendAnswer({
* code: offerCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await api.sendAnswer({
* code: offerCode,
* candidate: candidateData,
* side: 'offerer'
* });
* ```
*/
async sendAnswer(request: AnswerRequest): Promise<AnswerResponse> {
return this.request<AnswerResponse>('/answer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Polls for offer data from the other peer
*
* @param code - Offer code
* @param side - Which side is polling ('offerer' or 'answerer')
* @returns Offer data including offers, answers, and candidates
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await api.poll(offerCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await api.poll(offerCode, 'answerer');
* console.log('Received offer:', answererData.offer);
* ```
*/
async poll(
code: string,
side: Side
): Promise<PollOffererResponse | PollAnswererResponse> {
const request: PollRequest = { code, side };
return this.request<PollOffererResponse | PollAnswererResponse>('/poll', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Checks server health and version
*
* @returns Health status, timestamp, and version
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const health = await api.health();
* console.log('Server status:', health.status);
* console.log('Server version:', health.version);
* ```
*/
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health', {
method: 'GET',
});
}
/**
* Ends a session by deleting the offer from the server
*
* @param code - The offer code
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* await api.leave('my-offer-code');
* ```
*/
async leave(code: string): Promise<{ success: boolean }> {
return this.request<{ success: boolean }>('/leave', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
}

View File

@@ -1,388 +0,0 @@
import { RondevuOffers, RTCIceCandidateInit } from './offers.js';
/**
* Events emitted by RondevuConnection
*/
export interface RondevuConnectionEvents {
'connecting': () => void;
'connected': () => void;
'disconnected': () => void;
'error': (error: Error) => void;
'datachannel': (channel: RTCDataChannel) => void;
'track': (event: RTCTrackEvent) => void;
}
/**
* Options for creating a WebRTC connection
*/
export interface ConnectionOptions {
/**
* RTCConfiguration for the peer connection
* @default { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
*/
rtcConfig?: RTCConfiguration;
/**
* Topics to advertise this connection under
*/
topics: string[];
/**
* How long the offer should live (milliseconds)
* @default 300000 (5 minutes)
*/
ttl?: number;
/**
* Whether to create a data channel automatically (for offerer)
* @default true
*/
createDataChannel?: boolean;
/**
* Label for the automatically created data channel
* @default 'data'
*/
dataChannelLabel?: string;
}
/**
* High-level WebRTC connection manager for Rondevu
* Handles offer/answer exchange, ICE candidates, and connection lifecycle
*/
export class RondevuConnection {
private pc: RTCPeerConnection;
private offersApi: RondevuOffers;
private offerId?: string;
private role?: 'offerer' | 'answerer';
private icePollingInterval?: ReturnType<typeof setInterval>;
private answerPollingInterval?: ReturnType<typeof setInterval>;
private lastIceTimestamp: number = 0; // Start at 0 to get all candidates on first poll
private eventListeners: Map<keyof RondevuConnectionEvents, Set<Function>> = new Map();
private dataChannel?: RTCDataChannel;
private pendingIceCandidates: RTCIceCandidateInit[] = [];
/**
* Current connection state
*/
get connectionState(): RTCPeerConnectionState {
return this.pc.connectionState;
}
/**
* The offer ID for this connection
*/
get id(): string | undefined {
return this.offerId;
}
/**
* Get the primary data channel (if created)
*/
get channel(): RTCDataChannel | undefined {
return this.dataChannel;
}
constructor(
offersApi: RondevuOffers,
private rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
) {
this.offersApi = offersApi;
this.pc = new RTCPeerConnection(rtcConfig);
this.setupPeerConnection();
}
/**
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.pc.onicecandidate = async (event) => {
if (event.candidate) {
// Convert RTCIceCandidate to RTCIceCandidateInit (plain object)
const candidateData: RTCIceCandidateInit = {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment,
};
if (this.offerId) {
// offerId is set, send immediately (trickle ICE)
try {
await this.offersApi.addIceCandidates(this.offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
} else {
// offerId not set yet, buffer the candidate
this.pendingIceCandidates.push(candidateData);
}
}
};
this.pc.onconnectionstatechange = () => {
switch (this.pc.connectionState) {
case 'connecting':
this.emit('connecting');
break;
case 'connected':
this.emit('connected');
// Stop polling once connected - we have all the ICE candidates we need
this.stopPolling();
break;
case 'disconnected':
case 'failed':
case 'closed':
this.emit('disconnected');
this.stopPolling();
break;
}
};
this.pc.ondatachannel = (event) => {
this.dataChannel = event.channel;
this.emit('datachannel', event.channel);
};
this.pc.ontrack = (event) => {
this.emit('track', event);
};
this.pc.onicecandidateerror = (event) => {
console.error('ICE candidate error:', event);
};
}
/**
* Flush buffered ICE candidates (trickle ICE support)
*/
private async flushPendingIceCandidates(): Promise<void> {
if (this.pendingIceCandidates.length > 0 && this.offerId) {
try {
await this.offersApi.addIceCandidates(this.offerId, this.pendingIceCandidates);
this.pendingIceCandidates = [];
} catch (err) {
console.error('Error flushing pending ICE candidates:', err);
}
}
}
/**
* Create an offer and advertise on topics
*/
async createOffer(options: ConnectionOptions): Promise<string> {
this.role = 'offerer';
// Create data channel if requested
if (options.createDataChannel !== false) {
this.dataChannel = this.pc.createDataChannel(
options.dataChannelLabel || 'data'
);
this.emit('datachannel', this.dataChannel);
}
// Create WebRTC offer
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
// Create offer on Rondevu server
const offers = await this.offersApi.create([{
sdp: offer.sdp!,
topics: options.topics,
ttl: options.ttl || 300000
}]);
this.offerId = offers[0].id;
// Flush any ICE candidates that were generated during offer creation
await this.flushPendingIceCandidates();
// Start polling for answers
this.startAnswerPolling();
return this.offerId;
}
/**
* Answer an existing offer
*/
async answer(offerId: string, offerSdp: string): Promise<void> {
this.role = 'answerer';
// Set remote description
await this.pc.setRemoteDescription({
type: 'offer',
sdp: offerSdp
});
// Create answer
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
// Send answer to server FIRST
// This registers us as the answerer before ICE candidates arrive
await this.offersApi.answer(offerId, answer.sdp!);
// Now set offerId to enable ICE candidate sending
// This prevents a race condition where ICE candidates arrive before answer is registered
this.offerId = offerId;
// Flush any ICE candidates that were generated during answer creation
await this.flushPendingIceCandidates();
// Start polling for ICE candidates
this.startIcePolling();
}
/**
* Start polling for answers (offerer only)
*/
private startAnswerPolling(): void {
if (this.role !== 'offerer' || !this.offerId) return;
this.answerPollingInterval = setInterval(async () => {
try {
const answers = await this.offersApi.getAnswers();
const myAnswer = answers.find(a => a.offerId === this.offerId);
if (myAnswer) {
// Set remote description
await this.pc.setRemoteDescription({
type: 'answer',
sdp: myAnswer.sdp
});
// Stop answer polling, start ICE polling
this.stopAnswerPolling();
this.startIcePolling();
}
} catch (err) {
console.error('Error polling for answers:', err);
// Stop polling if offer expired/not found
if (err instanceof Error && err.message.includes('not found')) {
this.stopPolling();
}
}
}, 2000);
}
/**
* Start polling for ICE candidates
*/
private startIcePolling(): void {
if (!this.offerId) return;
this.icePollingInterval = setInterval(async () => {
if (!this.offerId) return;
try {
const candidates = await this.offersApi.getIceCandidates(
this.offerId,
this.lastIceTimestamp
);
for (const cand of candidates) {
// Use the candidate object directly - it's already RTCIceCandidateInit
await this.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
this.lastIceTimestamp = cand.createdAt;
}
} catch (err) {
console.error('Error polling for ICE candidates:', err);
// Stop polling if offer expired/not found
if (err instanceof Error && err.message.includes('not found')) {
this.stopPolling();
}
}
}, 1000);
}
/**
* Stop answer polling
*/
private stopAnswerPolling(): void {
if (this.answerPollingInterval) {
clearInterval(this.answerPollingInterval);
this.answerPollingInterval = undefined;
}
}
/**
* Stop ICE polling
*/
private stopIcePolling(): void {
if (this.icePollingInterval) {
clearInterval(this.icePollingInterval);
this.icePollingInterval = undefined;
}
}
/**
* Stop all polling
*/
private stopPolling(): void {
this.stopAnswerPolling();
this.stopIcePolling();
}
/**
* Add event listener
*/
on<K extends keyof RondevuConnectionEvents>(
event: K,
listener: RondevuConnectionEvents[K]
): void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event)!.add(listener);
}
/**
* Remove event listener
*/
off<K extends keyof RondevuConnectionEvents>(
event: K,
listener: RondevuConnectionEvents[K]
): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.delete(listener);
}
}
/**
* Emit event
*/
private emit<K extends keyof RondevuConnectionEvents>(
event: K,
...args: Parameters<RondevuConnectionEvents[K]>
): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach(listener => {
(listener as any)(...args);
});
}
}
/**
* Add a media track to the connection
*/
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
return this.pc.addTrack(track, ...streams);
}
/**
* Close the connection and clean up
*/
close(): void {
this.stopPolling();
this.pc.close();
this.eventListeners.clear();
}
}

View File

@@ -1,86 +0,0 @@
/**
* Simple EventEmitter implementation for browser and Node.js compatibility
*/
export class EventEmitter {
private events: Map<string, Set<Function>>;
constructor() {
this.events = new Map();
}
/**
* Register an event listener
*/
on(event: string, listener: Function): this {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(listener);
return this;
}
/**
* Register a one-time event listener
*/
once(event: string, listener: Function): this {
const onceWrapper = (...args: any[]) => {
this.off(event, onceWrapper);
listener.apply(this, args);
};
return this.on(event, onceWrapper);
}
/**
* Remove an event listener
*/
off(event: string, listener: Function): this {
const listeners = this.events.get(event);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.events.delete(event);
}
}
return this;
}
/**
* Emit an event
*/
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.size === 0) {
return false;
}
listeners.forEach(listener => {
try {
listener.apply(this, args);
} catch (err) {
console.error(`Error in ${event} event listener:`, err);
}
});
return true;
}
/**
* Remove all listeners for an event (or all events if not specified)
*/
removeAllListeners(event?: string): this {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
/**
* Get listener count for an event
*/
listenerCount(event: string): number {
const listeners = this.events.get(event);
return listeners ? listeners.size : 0;
}
}

View File

@@ -1,31 +1,29 @@
/**
* @xtr-dev/rondevu-client
* WebRTC peer signaling and discovery client with topic-based discovery
* WebRTC peer signaling client
*/
// Export main client class
export { Rondevu } from './rondevu.js';
export type { RondevuOptions } from './rondevu.js';
export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RondevuSignaler } from './rondevu-signaler.js'
// Export authentication
export { RondevuAuth } from './auth.js';
export type { Credentials, FetchFunction } from './auth.js';
// Export offers API
export { RondevuOffers } from './offers.js';
// Export types
export type {
CreateOfferRequest,
Signaler,
Binnable,
} from './types.js'
export type {
Credentials,
Keypair,
OfferRequest,
Offer,
ServiceRequest,
Service,
IceCandidate,
TopicInfo
} from './offers.js';
} from './api.js'
// Export bloom filter
export { BloomFilter } from './bloom.js';
export type { RondevuOptions, PublishServiceOptions } from './rondevu.js'
export type { PollingConfig } from './rondevu-signaler.js'
// Export connection manager
export { RondevuConnection } from './connection.js';
export type {
ConnectionOptions,
RondevuConnectionEvents
} from './connection.js';

View File

@@ -1,338 +0,0 @@
import { Credentials, FetchFunction } from './auth.js';
import { RondevuAuth } from './auth.js';
// Declare Buffer for Node.js compatibility
declare const Buffer: any;
export interface CreateOfferRequest {
id?: string;
sdp: string;
topics: string[];
ttl?: number;
}
export interface Offer {
id: string;
peerId: string;
sdp: string;
topics: string[];
createdAt?: number;
expiresAt: number;
lastSeen: number;
answererPeerId?: string;
answerSdp?: string;
answeredAt?: number;
}
/**
* RTCIceCandidateInit interface for environments without native WebRTC types
*/
export interface RTCIceCandidateInit {
candidate?: string;
sdpMid?: string | null;
sdpMLineIndex?: number | null;
usernameFragment?: string | null;
}
export interface IceCandidate {
candidate: RTCIceCandidateInit; // Full candidate object
peerId: string;
role: 'offerer' | 'answerer';
createdAt: number;
}
export interface TopicInfo {
topic: string;
activePeers: number;
}
export class RondevuOffers {
private fetchFn: FetchFunction;
constructor(
private baseUrl: string,
private credentials: Credentials,
fetchFn?: FetchFunction
) {
// Use provided fetch or fall back to global fetch
this.fetchFn = fetchFn || ((...args) => {
if (typeof globalThis.fetch === 'function') {
return globalThis.fetch(...args);
}
throw new Error(
'fetch is not available. Please provide a fetch implementation in the constructor options.'
);
});
}
/**
* Create one or more offers
*/
async create(offers: CreateOfferRequest[]): Promise<Offer[]> {
const response = await this.fetchFn(`${this.baseUrl}/offers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
body: JSON.stringify({ offers }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to create offers: ${error.error || response.statusText}`);
}
const data = await response.json();
return data.offers;
}
/**
* Find offers by topic with optional bloom filter
*/
async findByTopic(
topic: string,
options?: {
bloomFilter?: Uint8Array;
limit?: number;
}
): Promise<Offer[]> {
const params = new URLSearchParams();
if (options?.bloomFilter) {
// Convert to base64
const binaryString = String.fromCharCode(...Array.from(options.bloomFilter));
const base64 = typeof btoa !== 'undefined'
? btoa(binaryString)
: (typeof Buffer !== 'undefined' ? Buffer.from(options.bloomFilter).toString('base64') : '');
params.set('bloom', base64);
}
if (options?.limit) {
params.set('limit', options.limit.toString());
}
const url = `${this.baseUrl}/offers/by-topic/${encodeURIComponent(topic)}${
params.toString() ? '?' + params.toString() : ''
}`;
const response = await this.fetchFn(url, {
method: 'GET',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to find offers: ${error.error || response.statusText}`);
}
const data = await response.json();
return data.offers;
}
/**
* Get all offers from a specific peer
*/
async getByPeerId(peerId: string): Promise<{
offers: Offer[];
topics: string[];
}> {
const response = await this.fetchFn(`${this.baseUrl}/peers/${encodeURIComponent(peerId)}/offers`, {
method: 'GET',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get peer offers: ${error.error || response.statusText}`);
}
return await response.json();
}
/**
* Get topics with active peer counts (paginated)
*/
async getTopics(options?: {
limit?: number;
offset?: number;
}): Promise<{
topics: TopicInfo[];
total: number;
limit: number;
offset: number;
}> {
const params = new URLSearchParams();
if (options?.limit) {
params.set('limit', options.limit.toString());
}
if (options?.offset) {
params.set('offset', options.offset.toString());
}
const url = `${this.baseUrl}/topics${
params.toString() ? '?' + params.toString() : ''
}`;
const response = await this.fetchFn(url, {
method: 'GET',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get topics: ${error.error || response.statusText}`);
}
return await response.json();
}
/**
* Get own offers
*/
async getMine(): Promise<Offer[]> {
const response = await this.fetchFn(`${this.baseUrl}/offers/mine`, {
method: 'GET',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get own offers: ${error.error || response.statusText}`);
}
const data = await response.json();
return data.offers;
}
/**
* Update offer heartbeat
*/
async heartbeat(offerId: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/heartbeat`, {
method: 'PUT',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to update heartbeat: ${error.error || response.statusText}`);
}
}
/**
* Delete an offer
*/
async delete(offerId: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}`, {
method: 'DELETE',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to delete offer: ${error.error || response.statusText}`);
}
}
/**
* Answer an offer
*/
async answer(offerId: string, sdp: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
body: JSON.stringify({ sdp }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`);
}
}
/**
* Get answers to your offers
*/
async getAnswers(): Promise<Array<{
offerId: string;
answererId: string;
sdp: string;
answeredAt: number;
topics: string[];
}>> {
const response = await this.fetchFn(`${this.baseUrl}/offers/answers`, {
method: 'GET',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get answers: ${error.error || response.statusText}`);
}
const data = await response.json();
return data.answers;
}
/**
* Post ICE candidates for an offer
*/
async addIceCandidates(
offerId: string,
candidates: RTCIceCandidateInit[]
): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
body: JSON.stringify({ candidates }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`);
}
}
/**
* Get ICE candidates for an offer
*/
async getIceCandidates(offerId: string, since?: number): Promise<IceCandidate[]> {
const params = new URLSearchParams();
if (since !== undefined) {
params.set('since', since.toString());
}
const url = `${this.baseUrl}/offers/${encodeURIComponent(offerId)}/ice-candidates${
params.toString() ? '?' + params.toString() : ''
}`;
const response = await this.fetchFn(url, {
method: 'GET',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`);
}
const data = await response.json();
return data.candidates;
}
}

442
src/rondevu-signaler.ts Normal file
View File

@@ -0,0 +1,442 @@
import { Signaler, Binnable } from './types.js'
import { Rondevu } from './rondevu.js'
export interface PollingConfig {
initialInterval?: number // Default: 500ms
maxInterval?: number // Default: 5000ms
backoffMultiplier?: number // Default: 1.5
maxRetries?: number // Default: 50 (50 seconds max)
jitter?: boolean // Default: true
}
/**
* RondevuSignaler - Handles WebRTC signaling via Rondevu service
*
* Manages offer/answer exchange and ICE candidate polling for establishing
* WebRTC connections through the Rondevu signaling server.
*
* Supports configurable polling with exponential backoff and jitter to reduce
* server load and prevent thundering herd issues.
*
* @example
* ```typescript
* const signaler = new RondevuSignaler(
* rondevuService,
* 'chat.app@1.0.0',
* 'peer-username',
* { initialInterval: 500, maxInterval: 5000, jitter: true }
* )
*
* // For offerer:
* await signaler.setOffer(offer)
* signaler.addAnswerListener(answer => {
* // Handle remote answer
* })
*
* // For answerer:
* signaler.addOfferListener(offer => {
* // Handle remote offer
* })
* await signaler.setAnswer(answer)
* ```
*/
export class RondevuSignaler implements Signaler {
private offerId: string | null = null
private serviceFqn: string | null = null
private offerListeners: Array<(offer: RTCSessionDescriptionInit) => void> = []
private answerListeners: Array<(answer: RTCSessionDescriptionInit) => void> = []
private iceListeners: Array<(candidate: RTCIceCandidate) => void> = []
private answerPollingTimeout: ReturnType<typeof setTimeout> | null = null
private icePollingTimeout: ReturnType<typeof setTimeout> | null = null
private lastIceTimestamp = 0
private isPolling = false
private pollingConfig: Required<PollingConfig>
constructor(
private readonly rondevu: Rondevu,
private readonly service: string,
private readonly host?: string,
pollingConfig?: PollingConfig
) {
this.pollingConfig = {
initialInterval: pollingConfig?.initialInterval ?? 500,
maxInterval: pollingConfig?.maxInterval ?? 5000,
backoffMultiplier: pollingConfig?.backoffMultiplier ?? 1.5,
maxRetries: pollingConfig?.maxRetries ?? 50,
jitter: pollingConfig?.jitter ?? true
}
}
/**
* Publish an offer as a service
* Used by the offerer to make their offer available
*/
async setOffer(offer: RTCSessionDescriptionInit): Promise<void> {
if (!offer.sdp) {
throw new Error('Offer SDP is required')
}
// Publish service with the offer SDP
const publishedService = await this.rondevu.publishService({
serviceFqn: this.service,
offers: [{ sdp: offer.sdp }],
ttl: 300000, // 5 minutes
})
// Get the first offer from the published service
if (!publishedService.offers || publishedService.offers.length === 0) {
throw new Error('No offers returned from service publication')
}
this.offerId = publishedService.offers[0].offerId
this.serviceFqn = publishedService.serviceFqn
// Start polling for answer
this.startAnswerPolling()
// Start polling for ICE candidates
this.startIcePolling()
}
/**
* Send an answer to the offerer
* Used by the answerer to respond to an offer
*/
async setAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!answer.sdp) {
throw new Error('Answer SDP is required')
}
if (!this.serviceFqn || !this.offerId) {
throw new Error('No service FQN or offer ID available. Must receive offer first.')
}
// Send answer to the service
const result = await this.rondevu.getAPI().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp)
this.offerId = result.offerId
// Start polling for ICE candidates
this.startIcePolling()
}
/**
* Listen for incoming offers
* Used by the answerer to receive offers from the offerer
*/
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
this.offerListeners.push(callback)
// If we have a host, start searching for their service
if (this.host && !this.isPolling) {
this.searchForOffer()
}
// Return cleanup function
return () => {
const index = this.offerListeners.indexOf(callback)
if (index > -1) {
this.offerListeners.splice(index, 1)
}
}
}
/**
* Listen for incoming answers
* Used by the offerer to receive the answer from the answerer
*/
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
this.answerListeners.push(callback)
// Return cleanup function
return () => {
const index = this.answerListeners.indexOf(callback)
if (index > -1) {
this.answerListeners.splice(index, 1)
}
}
}
/**
* Send an ICE candidate to the remote peer
*/
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
if (!this.serviceFqn || !this.offerId) {
console.warn('Cannot send ICE candidate: no service FQN or offer ID')
return
}
const candidateData = candidate.toJSON()
// Skip empty candidates
if (!candidateData.candidate || candidateData.candidate === '') {
return
}
try {
await this.rondevu.getAPI().addOfferIceCandidates(
this.serviceFqn,
this.offerId,
[candidateData]
)
} catch (err) {
console.error('Failed to send ICE candidate:', err)
}
}
/**
* Listen for ICE candidates from the remote peer
*/
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
this.iceListeners.push(callback)
// Return cleanup function
return () => {
const index = this.iceListeners.indexOf(callback)
if (index > -1) {
this.iceListeners.splice(index, 1)
}
}
}
/**
* Search for an offer from the host
* Used by the answerer to find the offerer's service
*/
private async searchForOffer(): Promise<void> {
if (!this.host) {
throw new Error('No host specified for offer search')
}
this.isPolling = true
try {
// Get service by FQN (service should include @username)
const serviceFqn = `${this.service}@${this.host}`
const serviceData = await this.rondevu.getAPI().getService(serviceFqn)
if (!serviceData) {
console.warn(`No service found for ${serviceFqn}`)
this.isPolling = false
return
}
// Store service details
this.offerId = serviceData.offerId
this.serviceFqn = serviceData.serviceFqn
// Notify offer listeners
const offer: RTCSessionDescriptionInit = {
type: 'offer',
sdp: serviceData.sdp,
}
this.offerListeners.forEach(listener => {
try {
listener(offer)
} catch (err) {
console.error('Offer listener error:', err)
}
})
} catch (err) {
console.error('Failed to search for offer:', err)
this.isPolling = false
}
}
/**
* Start polling for answer (offerer side) with exponential backoff
*/
private startAnswerPolling(): void {
if (this.answerPollingTimeout || !this.serviceFqn || !this.offerId) {
return
}
let interval = this.pollingConfig.initialInterval
let retries = 0
const poll = async () => {
if (!this.serviceFqn || !this.offerId) {
this.stopAnswerPolling()
return
}
try {
const answer = await this.rondevu.getAPI().getOfferAnswer(this.serviceFqn, this.offerId)
if (answer && answer.sdp) {
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = answer.offerId
}
// Got answer - notify listeners and stop polling
const answerDesc: RTCSessionDescriptionInit = {
type: 'answer',
sdp: answer.sdp,
}
this.answerListeners.forEach(listener => {
try {
listener(answerDesc)
} catch (err) {
console.error('Answer listener error:', err)
}
})
// Stop polling once we get the answer
this.stopAnswerPolling()
return
}
// No answer yet - exponential backoff
retries++
if (retries > this.pollingConfig.maxRetries) {
console.warn('Max retries reached for answer polling')
this.stopAnswerPolling()
return
}
interval = Math.min(
interval * this.pollingConfig.backoffMultiplier,
this.pollingConfig.maxInterval
)
// Add jitter to prevent thundering herd
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.answerPollingTimeout = setTimeout(poll, finalInterval)
} catch (err) {
// 404 is expected when answer isn't available yet
if (err instanceof Error && !err.message?.includes('404')) {
console.error('Error polling for answer:', err)
}
// Retry with backoff
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.answerPollingTimeout = setTimeout(poll, finalInterval)
}
}
poll() // Start immediately
}
/**
* Stop polling for answer
*/
private stopAnswerPolling(): void {
if (this.answerPollingTimeout) {
clearTimeout(this.answerPollingTimeout)
this.answerPollingTimeout = null
}
}
/**
* Start polling for ICE candidates with adaptive backoff
*/
private startIcePolling(): void {
if (this.icePollingTimeout || !this.serviceFqn || !this.offerId) {
return
}
let interval = this.pollingConfig.initialInterval
const poll = async () => {
if (!this.serviceFqn || !this.offerId) {
this.stopIcePolling()
return
}
try {
const result = await this.rondevu
.getAPI()
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIceTimestamp)
let foundCandidates = false
for (const item of result.candidates) {
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
foundCandidates = true
try {
const rtcCandidate = new RTCIceCandidate(item.candidate)
this.iceListeners.forEach(listener => {
try {
listener(rtcCandidate)
} catch (err) {
console.error('ICE listener error:', err)
}
})
this.lastIceTimestamp = item.createdAt
} catch (err) {
console.warn('Failed to process ICE candidate:', err)
this.lastIceTimestamp = item.createdAt
}
} else {
this.lastIceTimestamp = item.createdAt
}
}
// If candidates found, reset interval to initial value
// Otherwise, increase interval with backoff
if (foundCandidates) {
interval = this.pollingConfig.initialInterval
} else {
interval = Math.min(
interval * this.pollingConfig.backoffMultiplier,
this.pollingConfig.maxInterval
)
}
// Add jitter
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.icePollingTimeout = setTimeout(poll, finalInterval)
} catch (err) {
// 404/410 means offer expired, stop polling
if (err instanceof Error && (err.message?.includes('404') || err.message?.includes('410'))) {
console.warn('Offer not found or expired, stopping ICE polling')
this.stopIcePolling()
} else if (err instanceof Error && !err.message?.includes('404')) {
console.error('Error polling for ICE candidates:', err)
// Continue polling despite errors
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.icePollingTimeout = setTimeout(poll, finalInterval)
}
}
}
poll() // Start immediately
}
/**
* Stop polling for ICE candidates
*/
private stopIcePolling(): void {
if (this.icePollingTimeout) {
clearTimeout(this.icePollingTimeout)
this.icePollingTimeout = null
}
}
/**
* Stop all polling and cleanup
*/
dispose(): void {
this.stopAnswerPolling()
this.stopIcePolling()
this.offerListeners = []
this.answerListeners = []
this.iceListeners = []
}
}

View File

@@ -1,103 +1,335 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js';
import { RondevuConnection, ConnectionOptions } from './connection.js';
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest, IceCandidate } from './api.js'
export interface RondevuOptions {
/**
* Base URL of the Rondevu server
* @default 'https://api.ronde.vu'
*/
baseUrl?: string;
apiUrl: string
username: string
keypair?: Keypair
credentials?: Credentials
}
export interface PublishServiceOptions {
serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice")
offers: Array<{ sdp: string }>
ttl?: number
}
/**
* Existing credentials (peerId + secret) to skip registration
*/
credentials?: Credentials;
/**
* Custom fetch implementation for environments without native fetch
* (Node.js < 18, some Workers environments, etc.)
* Rondevu - Complete WebRTC signaling client
*
* @example Node.js
* Provides a unified API for:
* - Username claiming with Ed25519 signatures
* - Service publishing with automatic signature generation
* - Service discovery (direct, random, paginated)
* - WebRTC signaling (offer/answer exchange, ICE relay)
* - Keypair management
*
* @example
* ```typescript
* import fetch from 'node-fetch';
* const client = new Rondevu({ fetch });
* // Initialize (generates keypair automatically)
* const rondevu = new Rondevu({
* apiUrl: 'https://signal.example.com',
* username: 'alice',
* })
*
* await rondevu.initialize()
*
* // Claim username (one time)
* await rondevu.claimUsername()
*
* // Publish a service
* const publishedService = await rondevu.publishService({
* serviceFqn: 'chat:1.0.0@alice',
* offers: [{ sdp: offerSdp }],
* ttl: 300000,
* })
*
* // Discover a service
* const service = await rondevu.getService('chat:1.0.0@bob')
*
* // Post answer
* await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
* ```
*/
fetch?: FetchFunction;
}
export class Rondevu {
readonly auth: RondevuAuth;
private _offers?: RondevuOffers;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
private readonly api: RondevuAPI
private readonly username: string
private keypair: Keypair | null = null
private usernameClaimed = false
constructor(options: RondevuOptions = {}) {
this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
this.fetchFn = options.fetch;
constructor(options: RondevuOptions) {
this.username = options.username
this.keypair = options.keypair || null
this.api = new RondevuAPI(options.apiUrl, options.credentials)
this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
if (options.credentials) {
this.credentials = options.credentials;
this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
console.log('[Rondevu] Constructor called:', {
username: this.username,
hasKeypair: !!this.keypair,
publicKey: this.keypair?.publicKey
})
}
// ============================================
// Initialization
// ============================================
/**
* Initialize the service - generates keypair if not provided
* Call this before using other methods
*/
async initialize(): Promise<void> {
console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair)
if (!this.keypair) {
console.log('[Rondevu] Generating new keypair...')
this.keypair = await RondevuAPI.generateKeypair()
console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
} else {
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
}
// Register with API if no credentials provided
if (!this.api['credentials']) {
const credentials = await this.api.register()
this.api.setCredentials(credentials)
}
}
// ============================================
// Username Management
// ============================================
/**
* Claim the username with Ed25519 signature
* Should be called once before publishing services
*/
async claimUsername(): Promise<void> {
if (!this.keypair) {
throw new Error('Not initialized. Call initialize() first.')
}
// Check if username is already claimed
const check = await this.api.checkUsername(this.username)
if (!check.available) {
// Verify it's claimed by us
if (check.publicKey === this.keypair.publicKey) {
this.usernameClaimed = true
return
}
throw new Error(`Username "${this.username}" is already claimed by another user`)
}
// Generate signature for username claim
const message = `claim:${this.username}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Claim the username
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
this.usernameClaimed = true
}
/**
* Get offers API (requires authentication)
* Check if username has been claimed (checks with server)
*/
get offers(): RondevuOffers {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
async isUsernameClaimed(): Promise<boolean> {
if (!this.keypair) {
return false
}
return this._offers;
try {
const check = await this.api.checkUsername(this.username)
// Debug logging
console.log('[Rondevu] Username check:', {
username: this.username,
available: check.available,
serverPublicKey: check.publicKey,
localPublicKey: this.keypair.publicKey,
match: check.publicKey === this.keypair.publicKey
})
// Username is claimed if it's not available and owned by our public key
const claimed = !check.available && check.publicKey === this.keypair.publicKey
// Update internal flag to match server state
this.usernameClaimed = claimed
return claimed
} catch (err) {
console.error('Failed to check username claim status:', err)
return false
}
}
// ============================================
// Service Publishing
// ============================================
/**
* Publish a service with automatic signature generation
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('Not initialized. Call initialize() first.')
}
if (!this.usernameClaimed) {
throw new Error(
'Username not claimed. Call claimUsername() first or the server will reject the service.'
)
}
const { serviceFqn, offers, ttl } = options
// Generate signature for service publication
const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Create service request
const serviceRequest: ServiceRequest = {
serviceFqn, // Must include @username
offers,
signature,
message,
ttl,
}
// Publish to server
return await this.api.publishService(serviceRequest)
}
// ============================================
// Service Discovery
// ============================================
/**
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async getService(serviceFqn: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
/**
* Register and initialize authenticated client
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create offers API instance
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
);
return this.credentials;
async discoverService(serviceVersion: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.discoverService(serviceVersion)
}
/**
* Check if client is authenticated
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
isAuthenticated(): boolean {
return !!this.credentials;
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{
services: Array<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}>
count: number
limit: number
offset: number
}> {
return await this.api.discoverServices(serviceVersion, limit, offset)
}
// ============================================
// WebRTC Signaling
// ============================================
/**
* Post answer SDP to specific offer
*/
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{
success: boolean
offerId: string
}> {
return await this.api.postOfferAnswer(serviceFqn, offerId, sdp)
}
/**
* Get current credentials
* Get answer SDP (offerer polls this)
*/
getCredentials(): Credentials | undefined {
return this.credentials;
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{
sdp: string
offerId: string
answererId: string
answeredAt: number
} | null> {
return await this.api.getOfferAnswer(serviceFqn, offerId)
}
/**
* Create a new WebRTC connection (requires authentication)
* This is a high-level helper that creates and manages WebRTC connections
*
* @param rtcConfig Optional RTCConfiguration for the peer connection
* @returns RondevuConnection instance
* Add ICE candidates to specific offer
*/
createConnection(rtcConfig?: RTCConfiguration): RondevuConnection {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{
count: number
offerId: string
}> {
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates)
}
return new RondevuConnection(this._offers, rtcConfig);
/**
* Get ICE candidates for specific offer (with polling support)
*/
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{
candidates: IceCandidate[]
offerId: string
}> {
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since)
}
// ============================================
// Utility Methods
// ============================================
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
return this.keypair
}
/**
* Get the username
*/
getUsername(): string {
return this.username
}
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
}
/**
* Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead
*/
getAPI(): RondevuAPI {
return this.api
}
}

View File

@@ -1,182 +1,20 @@
// ============================================================================
// Signaling Types
// ============================================================================
/**
* Core signaling types
*/
/**
* Session side - identifies which peer in a connection
* Cleanup function returned by listener methods
*/
export type Side = 'offerer' | 'answerer';
export type Binnable = () => void
/**
* Request body for POST /offer
* Signaler interface for WebRTC offer/answer/ICE exchange
*/
export interface CreateOfferRequest {
/** Peer identifier/metadata (max 1024 characters) */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Optional custom connection code (if not provided, server generates UUID) */
code?: string;
}
/**
* Response from POST /offer
*/
export interface CreateOfferResponse {
/** Unique session identifier (UUID) */
code: string;
}
/**
* Request body for POST /answer
*/
export interface AnswerRequest {
/** Session UUID from the offer */
code: string;
/** Response signaling data (required if candidate not provided) */
answer?: string;
/** Additional signaling data (required if answer not provided) */
candidate?: string;
/** Which peer is sending the data */
side: Side;
}
/**
* Response from POST /answer
*/
export interface AnswerResponse {
success: boolean;
}
/**
* Request body for POST /poll
*/
export interface PollRequest {
/** Session UUID */
code: string;
/** Which side is polling */
side: Side;
}
/**
* Response from POST /poll when side=offerer
*/
export interface PollOffererResponse {
/** Answer from answerer (null if not yet received) */
answer: string | null;
/** Additional signaling data from answerer */
answerCandidates: string[];
}
/**
* Response from POST /poll when side=answerer
*/
export interface PollAnswererResponse {
/** Offer from offerer */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
}
/**
* Response from POST /poll (union type)
*/
export type PollResponse = PollOffererResponse | PollAnswererResponse;
/**
* Response from GET / - server version information
*/
export interface VersionResponse {
/** Git commit hash or version identifier */
version: string;
}
/**
* Response from GET /health
*/
export interface HealthResponse {
status: 'ok';
timestamp: number;
version: string;
}
/**
* Error response structure
*/
export interface ErrorResponse {
error: string;
}
/**
* Client configuration options
*/
export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
// ============================================================================
// WebRTC Types
// ============================================================================
/**
* WebRTC polyfill for Node.js and other non-browser platforms
*/
export interface WebRTCPolyfill {
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
}
/**
* Configuration options for Rondevu WebRTC client
*/
export interface RondevuOptions {
/** Base URL of the Rondevu server (defaults to 'https://api.ronde.vu') */
baseUrl?: string;
/** Peer identifier (optional, auto-generated if not provided) */
peerId?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */
rtcConfig?: RTCConfiguration;
/** Polling interval in milliseconds (default: 1000) */
pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number;
/** WebRTC polyfill for Node.js (e.g., wrtc or @roamhq/wrtc) */
wrtc?: WebRTCPolyfill;
}
/**
* Connection role - whether this peer is creating or answering
*/
export type ConnectionRole = 'offerer' | 'answerer';
/**
* Parameters for creating a RondevuConnection
*/
export interface RondevuConnectionParams {
id: string;
topic?: string;
role: ConnectionRole;
pc: RTCPeerConnection;
localPeerId: string;
remotePeerId: string;
pollingInterval: number;
connectionTimeout: number;
wrtc?: WebRTCPolyfill;
}
/**
* Event map for RondevuConnection events
*/
export interface RondevuConnectionEvents {
connect: () => void;
disconnect: () => void;
error: (error: Error) => void;
datachannel: (channel: RTCDataChannel) => void;
stream: (stream: MediaStream) => void;
export interface Signaler {
addIceCandidate(candidate: RTCIceCandidate): Promise<void>
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
}