74 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:37:50 +01:00
5a47e0a397 Add WebRTC connection manager and fix race condition
- Add RondevuConnection class for high-level WebRTC management
- Handles offer/answer exchange, ICE candidates, and data channels
- Fix race condition in answer() method (register answerer before sending ICE)
- Add event-driven API (connecting, connected, disconnected, error, datachannel, track)
- Update README with connection manager examples
- Export new connection types and classes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes: RTCPeerConnection not defined error in Node.js

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

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

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

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

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

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

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

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

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

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

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

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

This change works around browser restriction on Origin header modification.

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

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

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

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

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)

667
README.md
View File

@@ -1,60 +1,659 @@
# Rondevu # Rondevu Client
🎯 Meet WebRTC peers by topic, by peer ID, or by connection ID.
## @xtr-dev/rondevu-client
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
TypeScript Rondevu HTTP and WebRTC client, for simple peer discovery and connection. 🌐 **WebRTC with durable connections and automatic reconnection**
### Install TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing.
**Related repositories:**
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
---
## Features
- **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
- **Username Claiming**: Cryptographic ownership with Ed25519 signatures
- **Service Publishing**: Package-style naming (com.example.chat@1.0.0)
- **TypeScript**: Full type safety and autocomplete
- **Configurable**: All timeouts, retry limits, and queue sizes are configurable
## Install
```bash ```bash
npm install @xtr-dev/rondevu-client npm install @xtr-dev/rondevu-client
``` ```
### Usage ## Quick Start
### Publishing a Service (Alice)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client';
const rdv = new Rondevu({ baseUrl: 'https://server.com' }); // Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Connect by topic // Step 1: Claim username (one-time)
const conn = await rdv.join('room'); const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// Or connect by ID // Step 2: Expose service with handler
const conn = await rdv.connect('meeting-123'); const keypair = client.usernames.loadKeypairFromStorage('alice');
// Use the connection const service = await client.exposeService({
conn.on('connect', () => { username: 'alice',
const channel = conn.dataChannel('chat'); privateKey: keypair.privateKey,
channel.send('Hello!'); serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // Handle 10 concurrent connections
handler: (channel, connectionId) => {
console.log(`📡 New connection: ${connectionId}`);
channel.on('message', (data) => {
console.log('📥 Received:', data);
channel.send(`Echo: ${data}`);
});
channel.on('close', () => {
console.log(`👋 Connection ${connectionId} closed`);
});
}
});
// Start the service
const info = await service.start();
console.log(`Service published with UUID: ${info.uuid}`);
console.log('Waiting for connections...');
// Later: stop the service
await service.stop();
```
### Connecting to a Service (Bob)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Connect to Alice's service
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 5
});
// Create a durable channel
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('📥 Received:', data);
});
channel.on('open', () => {
console.log('✅ Channel open');
channel.send('Hello Alice!');
});
// Listen for connection events
connection.on('connected', () => {
console.log('🎉 Connected to Alice');
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`);
});
connection.on('disconnected', () => {
console.log('🔌 Disconnected');
});
connection.on('failed', (error) => {
console.error('❌ Connection failed permanently:', error);
});
// Establish the connection
await connection.connect();
// Messages sent during disconnection are automatically queued
channel.send('This will be queued if disconnected');
// Later: close the connection
await connection.close();
```
## Core Concepts
### DurableConnection
Manages WebRTC peer lifecycle with automatic reconnection:
- Automatically reconnects when connection drops
- Exponential backoff with jitter (1s → 2s → 4s → 8s → ... max 30s)
- Configurable max retry attempts (default: 10)
- Manages multiple DurableChannel instances
### DurableChannel
Wraps RTCDataChannel with message queuing:
- Queues messages during disconnection
- Flushes queue on reconnection
- Configurable queue size and message age limits
- RTCDataChannel-compatible API with event emitters
### DurableService
Server-side service with TTL auto-refresh:
- Automatically republishes service before TTL expires
- Creates DurableConnection for each incoming peer
- Manages connection pool for multiple simultaneous connections
## API Reference
### Main Client
```typescript
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu', // optional, default shown
credentials?: { peerId, secret }, // optional, skip registration
fetch?: customFetch, // optional, for Node.js < 18
RTCPeerConnection?: RTCPeerConnection, // optional, for Node.js
RTCSessionDescription?: RTCSessionDescription,
RTCIceCandidate?: RTCIceCandidate
});
// Register and get credentials
const creds = await client.register();
// { peerId: '...', secret: '...' }
// Check if authenticated
client.isAuthenticated(); // boolean
// Get current credentials
client.getCredentials(); // { peerId, secret } | undefined
```
### Username API
```typescript
// Check username availability
const check = await client.usernames.checkUsername('alice');
// { available: true } or { available: false, expiresAt: number, publicKey: string }
// Claim username with new keypair
const claim = await client.usernames.claimUsername('alice');
// { username, publicKey, privateKey, claimedAt, expiresAt }
// Save keypair to localStorage
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// Load keypair from localStorage
const keypair = client.usernames.loadKeypairFromStorage('alice');
// { publicKey, privateKey } | null
```
**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
### Durable Service API
```typescript
// Expose a durable service
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
// Service options
isPublic: true, // optional, default: false
metadata: { version: '1.0' }, // optional
ttl: 300000, // optional, default: 5 minutes
ttlRefreshMargin: 0.2, // optional, refresh at 80% of TTL
// Connection pooling
poolSize: 10, // optional, default: 1
pollingInterval: 2000, // optional, default: 2000ms
// Connection options (applied to incoming connections)
maxReconnectAttempts: 10, // optional, default: 10
reconnectBackoffBase: 1000, // optional, default: 1000ms
reconnectBackoffMax: 30000, // optional, default: 30000ms
reconnectJitter: 0.2, // optional, default: 0.2 (±20%)
connectionTimeout: 30000, // optional, default: 30000ms
// Message queuing
maxQueueSize: 1000, // optional, default: 1000
maxMessageAge: 60000, // optional, default: 60000ms (1 minute)
// WebRTC configuration
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
},
// Connection handler
handler: (channel, connectionId) => {
// Handle incoming connection
channel.on('message', (data) => {
console.log('Received:', data);
channel.send(`Echo: ${data}`);
});
}
});
// Start the service
const info = await service.start();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 }
// Get active connections
const connections = service.getActiveConnections();
// ['conn-123', 'conn-456']
// Get service info
const serviceInfo = service.getServiceInfo();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 } | null
// Stop the service
await service.stop();
```
**Service Events:**
```typescript
service.on('published', (serviceId, uuid) => {
console.log(`Service published: ${uuid}`);
});
service.on('connection', (connectionId) => {
console.log(`New connection: ${connectionId}`);
});
service.on('disconnection', (connectionId) => {
console.log(`Connection closed: ${connectionId}`);
});
service.on('ttl-refreshed', (expiresAt) => {
console.log(`TTL refreshed, expires at: ${new Date(expiresAt)}`);
});
service.on('error', (error, context) => {
console.error(`Service error (${context}):`, error);
});
service.on('closed', () => {
console.log('Service stopped');
}); });
``` ```
### API ### Durable Connection API
**Main Methods:** ```typescript
- `rdv.join(topic)` - Auto-connect to first peer in topic // Connect by username and service FQN
- `rdv.join(topic, {filter})` - Connect to specific peer by ID const connection = await client.connect('alice', 'chat@1.0.0', {
- `rdv.create(id, topic)` - Create connection for others to join // Connection options
- `rdv.connect(id)` - Join connection by ID maxReconnectAttempts: 10, // optional, default: 10
reconnectBackoffBase: 1000, // optional, default: 1000ms
reconnectBackoffMax: 30000, // optional, default: 30000ms
reconnectJitter: 0.2, // optional, default: 0.2 (±20%)
connectionTimeout: 30000, // optional, default: 30000ms
// Message queuing
maxQueueSize: 1000, // optional, default: 1000
maxMessageAge: 60000, // optional, default: 60000ms
// WebRTC configuration
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
}
});
// Connect by UUID
const connection2 = await client.connectByUuid('service-uuid-here', {
maxReconnectAttempts: 5
});
// Create channels before connecting
const channel = connection.createChannel('main');
const fileChannel = connection.createChannel('files', {
ordered: false,
maxRetransmits: 3
});
// Get existing channel
const existingChannel = connection.getChannel('main');
// Check connection state
const state = connection.getState();
// 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed' | 'closed'
const isConnected = connection.isConnected();
// Connect
await connection.connect();
// Close connection
await connection.close();
```
**Connection Events:** **Connection Events:**
- `connect` - Connection established ```typescript
- `disconnect` - Connection closed connection.on('state', (newState, previousState) => {
- `datachannel` - Remote peer created data channel console.log(`State: ${previousState}${newState}`);
- `stream` - Remote media stream received });
- `error` - Error occurred
**Connection Methods:** connection.on('connected', () => {
- `conn.dataChannel(label)` - Get or create data channel console.log('Connected');
- `conn.addStream(stream)` - Add media stream });
- `conn.getPeerConnection()` - Get underlying RTCPeerConnection
- `conn.close()` - Close connection
### License connection.on('reconnecting', (attempt, maxAttempts, delay) => {
console.log(`Reconnecting (${attempt}/${maxAttempts}) in ${delay}ms`);
});
connection.on('disconnected', () => {
console.log('Disconnected');
});
connection.on('failed', (error, permanent) => {
console.error('Connection failed:', error, 'Permanent:', permanent);
});
connection.on('closed', () => {
console.log('Connection closed');
});
```
### Durable Channel API
```typescript
const channel = connection.createChannel('chat', {
ordered: true, // optional, default: true
maxRetransmits: undefined // optional, for unordered channels
});
// Send data (queued if disconnected)
channel.send('Hello!');
channel.send(new ArrayBuffer(1024));
channel.send(new Blob(['data']));
// Check state
const state = channel.readyState;
// 'connecting' | 'open' | 'closing' | 'closed'
// Get buffered amount
const buffered = channel.bufferedAmount;
// Set buffered amount low threshold
channel.bufferedAmountLowThreshold = 16 * 1024; // 16KB
// Get queue size (for debugging)
const queueSize = channel.getQueueSize();
// Close channel
channel.close();
```
**Channel Events:**
```typescript
channel.on('open', () => {
console.log('Channel open');
});
channel.on('message', (data) => {
console.log('Received:', data);
});
channel.on('error', (error) => {
console.error('Channel error:', error);
});
channel.on('close', () => {
console.log('Channel closed');
});
channel.on('bufferedAmountLow', () => {
console.log('Buffer drained, safe to send more');
});
channel.on('queueOverflow', (droppedCount) => {
console.warn(`Queue overflow: ${droppedCount} messages dropped`);
});
```
## Configuration Options
### Connection Configuration
```typescript
interface DurableConnectionConfig {
maxReconnectAttempts?: number; // default: 10
reconnectBackoffBase?: number; // default: 1000 (1 second)
reconnectBackoffMax?: number; // default: 30000 (30 seconds)
reconnectJitter?: number; // default: 0.2 (±20%)
connectionTimeout?: number; // default: 30000 (30 seconds)
maxQueueSize?: number; // default: 1000 messages
maxMessageAge?: number; // default: 60000 (1 minute)
rtcConfig?: RTCConfiguration;
}
```
### Service Configuration
```typescript
interface DurableServiceConfig extends DurableConnectionConfig {
username: string;
privateKey: string;
serviceFqn: string;
isPublic?: boolean; // default: false
metadata?: Record<string, any>;
ttl?: number; // default: 300000 (5 minutes)
ttlRefreshMargin?: number; // default: 0.2 (refresh at 80%)
poolSize?: number; // default: 1
pollingInterval?: number; // default: 2000 (2 seconds)
}
```
## Examples
### Chat Application
```typescript
// Server
const client = new Rondevu();
await client.register();
const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
const keypair = client.usernames.loadKeypairFromStorage('alice');
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 50, // Handle 50 concurrent users
handler: (channel, connectionId) => {
console.log(`User ${connectionId} joined`);
channel.on('message', (data) => {
console.log(`[${connectionId}]: ${data}`);
// Broadcast to all users (implement your broadcast logic)
});
channel.on('close', () => {
console.log(`User ${connectionId} left`);
});
}
});
await service.start();
// Client
const client2 = new Rondevu();
await client2.register();
const connection = await client2.connect('alice', 'chat@1.0.0');
const channel = connection.createChannel('chat');
channel.on('message', (data) => {
console.log('Message:', data);
});
await connection.connect();
channel.send('Hello everyone!');
```
### File Transfer with Progress
```typescript
// Server
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'files@1.0.0',
handler: (channel, connectionId) => {
channel.on('message', async (data) => {
const request = JSON.parse(data);
if (request.action === 'download') {
const file = await fs.readFile(request.path);
const chunkSize = 16 * 1024; // 16KB chunks
for (let i = 0; i < file.byteLength; i += chunkSize) {
const chunk = file.slice(i, i + chunkSize);
channel.send(chunk);
// Wait for buffer to drain if needed
while (channel.bufferedAmount > 16 * 1024 * 1024) { // 16MB
await new Promise(resolve => setTimeout(resolve, 100));
}
}
channel.send(JSON.stringify({ done: true }));
}
});
}
});
await service.start();
// Client
const connection = await client.connect('alice', 'files@1.0.0');
const channel = connection.createChannel('files');
const chunks = [];
channel.on('message', (data) => {
if (typeof data === 'string') {
const msg = JSON.parse(data);
if (msg.done) {
const blob = new Blob(chunks);
console.log('Download complete:', blob.size, 'bytes');
}
} else {
chunks.push(data);
console.log('Progress:', chunks.length * 16 * 1024, 'bytes');
}
});
await connection.connect();
channel.send(JSON.stringify({ action: 'download', path: '/file.zip' }));
```
## Platform-Specific Setup
### Modern Browsers
Works out of the box - no additional setup needed.
### Node.js 18+
Native fetch is available, but you need WebRTC polyfills:
```bash
npm install wrtc
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
```
### Node.js < 18
Install both fetch and WebRTC polyfills:
```bash
npm install node-fetch wrtc
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
```
## TypeScript
All types are exported:
```typescript
import type {
// Client types
Credentials,
RondevuOptions,
// Username types
UsernameCheckResult,
UsernameClaimResult,
// Durable connection types
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
QueuedMessage,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo
} from '@xtr-dev/rondevu-client';
```
## Migration from v0.8.x
v0.9.0 is a **breaking change** that replaces the low-level APIs with high-level durable connections. See [MIGRATION.md](./MIGRATION.md) for detailed migration guide.
**Key Changes:**
- ❌ Removed: `client.services.*`, `client.discovery.*`, `client.createPeer()` (low-level APIs)
- ✅ Added: `client.exposeService()`, `client.connect()`, `client.connectByUuid()` (durable APIs)
- ✅ Changed: Focus on durable connections with automatic reconnection and message queuing
## License
MIT MIT

49
package-lock.json generated Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.0.4", "version": "0.9.1",
"description": "TypeScript client for Rondevu peer signaling and discovery server", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -25,5 +25,8 @@
"files": [ "files": [
"dist", "dist",
"README.md" "README.md"
] ],
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
} }

62
src/auth.ts Normal file
View File

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

View File

@@ -1,223 +0,0 @@
import {
RondevuClientOptions,
ListTopicsResponse,
ListSessionsResponse,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
HealthResponse,
ErrorResponse,
Side,
} from './types';
/**
* HTTP client for Rondevu peer signaling and discovery server
*/
export class RondevuClient {
private readonly baseUrl: string;
private readonly origin: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu client instance
* @param options - Client configuration options
*/
constructor(options: RondevuClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.origin = options.origin || new URL(this.baseUrl).origin;
this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis);
}
/**
* Makes an HTTP request to the Rondevu server
*/
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Origin': this.origin,
...(options.headers as Record<string, string>),
};
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await this.fetchImpl(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
const error = data as ErrorResponse;
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return data as T;
}
/**
* Lists all topics with peer counts
*
* @param page - Page number (starting from 1)
* @param limit - Results per page (max 1000)
* @returns List of topics with pagination info
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { topics, pagination } = await client.listTopics();
* console.log(`Found ${topics.length} topics`);
* ```
*/
async listTopics(page = 1, limit = 100): Promise<ListTopicsResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
return this.request<ListTopicsResponse>(`/?${params}`, {
method: 'GET',
});
}
/**
* Discovers available peers for a given topic
*
* @param topic - Topic identifier
* @returns List of available sessions
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { sessions } = await client.listSessions('my-room');
* const otherPeers = sessions.filter(s => s.peerId !== myPeerId);
* ```
*/
async listSessions(topic: string): Promise<ListSessionsResponse> {
return this.request<ListSessionsResponse>(`/${encodeURIComponent(topic)}/sessions`, {
method: 'GET',
});
}
/**
* Announces peer availability and creates a new session
*
* @param topic - Topic identifier for grouping peers (max 1024 characters)
* @param request - Offer details including peer ID and signaling data
* @returns Unique session code (UUID)
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const { code } = await client.createOffer('my-room', {
* peerId: 'peer-123',
* offer: signalingData
* });
* console.log('Session code:', code);
* ```
*/
async createOffer(
topic: string,
request: CreateOfferRequest
): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>(
`/${encodeURIComponent(topic)}/offer`,
{
method: 'POST',
body: JSON.stringify(request),
}
);
}
/**
* Sends an answer or candidate to an existing session
*
* @param request - Answer details including session code and signaling data
* @returns Success confirmation
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
*
* // Send answer
* await client.sendAnswer({
* code: sessionCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await client.sendAnswer({
* code: sessionCode,
* candidate: candidateData,
* side: 'offerer'
* });
* ```
*/
async sendAnswer(request: AnswerRequest): Promise<AnswerResponse> {
return this.request<AnswerResponse>('/answer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Polls for session data from the other peer
*
* @param code - Session UUID
* @param side - Which side is polling ('offerer' or 'answerer')
* @returns Session data including offers, answers, and candidates
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await client.poll(sessionCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await client.poll(sessionCode, 'answerer');
* console.log('Received offer:', answererData.offer);
* ```
*/
async poll(
code: string,
side: Side
): Promise<PollOffererResponse | PollAnswererResponse> {
const request: PollRequest = { code, side };
return this.request<PollOffererResponse | PollAnswererResponse>('/poll', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Checks server health
*
* @returns Health status and timestamp
*
* @example
* ```typescript
* const client = new RondevuClient({ baseUrl: 'https://example.com' });
* const health = await client.health();
* console.log('Server status:', health.status);
* ```
*/
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health', {
method: 'GET',
});
}
}

View File

@@ -1,310 +0,0 @@
import { EventEmitter } from './event-emitter';
import { RondevuClient } from './client';
import { RondevuConnectionParams } from './types';
/**
* Represents a WebRTC connection with automatic signaling and ICE exchange
*/
export class RondevuConnection extends EventEmitter {
readonly id: string;
readonly topic: string;
readonly role: 'offerer' | 'answerer';
readonly remotePeerId: string;
private pc: RTCPeerConnection;
private client: RondevuClient;
private localPeerId: string;
private dataChannels: Map<string, RTCDataChannel>;
private pollingInterval?: ReturnType<typeof setInterval>;
private pollingIntervalMs: number;
private connectionTimeoutMs: number;
private connectionTimer?: ReturnType<typeof setTimeout>;
private isPolling: boolean = false;
private isClosed: boolean = false;
constructor(params: RondevuConnectionParams, client: RondevuClient) {
super();
this.id = params.id;
this.topic = params.topic;
this.role = params.role;
this.pc = params.pc;
this.localPeerId = params.localPeerId;
this.remotePeerId = params.remotePeerId;
this.client = client;
this.dataChannels = new Map();
this.pollingIntervalMs = params.pollingInterval;
this.connectionTimeoutMs = params.connectionTimeout;
this.setupEventHandlers();
this.startConnectionTimeout();
}
/**
* Setup RTCPeerConnection event handlers
*/
private setupEventHandlers(): void {
// ICE candidate gathering
this.pc.onicecandidate = (event) => {
if (event.candidate && !this.isClosed) {
this.sendIceCandidate(event.candidate).catch((err) => {
this.emit('error', new Error(`Failed to send ICE candidate: ${err.message}`));
});
}
};
// Connection state changes
this.pc.onconnectionstatechange = () => {
this.handleConnectionStateChange();
};
// Remote data channels
this.pc.ondatachannel = (event) => {
this.handleRemoteDataChannel(event.channel);
};
// Remote media streams
this.pc.ontrack = (event) => {
if (event.streams && event.streams[0]) {
this.emit('stream', event.streams[0]);
}
};
// ICE connection state changes
this.pc.oniceconnectionstatechange = () => {
const state = this.pc.iceConnectionState;
if (state === 'failed' || state === 'closed') {
this.emit('error', new Error(`ICE connection ${state}`));
if (state === 'failed') {
this.close();
}
}
};
}
/**
* Handle RTCPeerConnection state changes
*/
private handleConnectionStateChange(): void {
const state = this.pc.connectionState;
switch (state) {
case 'connected':
this.clearConnectionTimeout();
this.stopPolling();
this.emit('connect');
break;
case 'disconnected':
this.emit('disconnect');
break;
case 'failed':
this.emit('error', new Error('Connection failed'));
this.close();
break;
case 'closed':
this.emit('disconnect');
break;
}
}
/**
* Send an ICE candidate to the remote peer via signaling server
*/
private async sendIceCandidate(candidate: RTCIceCandidate): Promise<void> {
try {
await this.client.sendAnswer({
code: this.id,
candidate: JSON.stringify(candidate.toJSON()),
side: this.role,
});
} catch (err: any) {
throw new Error(`Failed to send ICE candidate: ${err.message}`);
}
}
/**
* Start polling for remote session data (answer/candidates)
*/
startPolling(): void {
if (this.isPolling || this.isClosed) {
return;
}
this.isPolling = true;
// Poll immediately
this.poll().catch((err) => {
this.emit('error', new Error(`Poll error: ${err.message}`));
});
// Set up interval polling
this.pollingInterval = setInterval(() => {
this.poll().catch((err) => {
this.emit('error', new Error(`Poll error: ${err.message}`));
});
}, this.pollingIntervalMs);
}
/**
* Stop polling
*/
private stopPolling(): void {
this.isPolling = false;
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = undefined;
}
}
/**
* Poll the signaling server for remote data
*/
private async poll(): Promise<void> {
if (this.isClosed) {
this.stopPolling();
return;
}
try {
const response = await this.client.poll(this.id, this.role);
if (this.role === 'offerer') {
const offererResponse = response as { answer: string | null; answerCandidates: string[] };
// Apply answer if received and not yet applied
if (offererResponse.answer && !this.pc.currentRemoteDescription) {
await this.pc.setRemoteDescription({
type: 'answer',
sdp: offererResponse.answer,
});
}
// Apply ICE candidates
if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) {
for (const candidateStr of offererResponse.answerCandidates) {
try {
const candidate = JSON.parse(candidateStr);
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
}
}
}
} else {
// Answerer role
const answererResponse = response as { offer: string; offerCandidates: string[] };
// Apply ICE candidates from offerer
if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) {
for (const candidateStr of answererResponse.offerCandidates) {
try {
const candidate = JSON.parse(candidateStr);
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
}
}
}
}
} catch (err: any) {
// Session not found or expired
if (err.message.includes('404') || err.message.includes('not found')) {
this.emit('error', new Error('Session not found or expired'));
this.close();
}
throw err;
}
}
/**
* Handle remotely created data channel
*/
private handleRemoteDataChannel(channel: RTCDataChannel): void {
this.dataChannels.set(channel.label, channel);
this.emit('datachannel', channel);
}
/**
* Get or create a data channel
*/
dataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
let channel = this.dataChannels.get(label);
if (!channel) {
channel = this.pc.createDataChannel(label, options);
this.dataChannels.set(label, channel);
}
return channel;
}
/**
* Add a local media stream to the connection
*/
addStream(stream: MediaStream): void {
stream.getTracks().forEach(track => {
this.pc.addTrack(track, stream);
});
}
/**
* Get the underlying RTCPeerConnection for advanced usage
*/
getPeerConnection(): RTCPeerConnection {
return this.pc;
}
/**
* Start connection timeout
*/
private startConnectionTimeout(): void {
this.connectionTimer = setTimeout(() => {
if (this.pc.connectionState !== 'connected') {
this.emit('error', new Error('Connection timeout'));
this.close();
}
}, this.connectionTimeoutMs);
}
/**
* Clear connection timeout
*/
private clearConnectionTimeout(): void {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer);
this.connectionTimer = undefined;
}
}
/**
* Close the connection and cleanup resources
*/
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.stopPolling();
this.clearConnectionTimeout();
// Close all data channels
this.dataChannels.forEach(dc => {
if (dc.readyState === 'open' || dc.readyState === 'connecting') {
dc.close();
}
});
this.dataChannels.clear();
// Close peer connection
if (this.pc.connectionState !== 'closed') {
this.pc.close();
}
this.emit('disconnect');
}
}

361
src/durable/channel.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* DurableChannel - Message queueing wrapper for RTCDataChannel
*
* Provides automatic message queuing during disconnections and transparent
* flushing when the connection is re-established.
*/
import { EventEmitter } from '../event-emitter.js';
import {
DurableChannelState
} from './types.js';
import type {
DurableChannelConfig,
DurableChannelEvents,
QueuedMessage
} from './types.js';
/**
* Default configuration for durable channels
*/
const DEFAULT_CONFIG = {
maxQueueSize: 1000,
maxMessageAge: 60000, // 1 minute
ordered: true,
maxRetransmits: undefined
} as const;
/**
* Durable channel that survives WebRTC peer connection drops
*
* The DurableChannel wraps an RTCDataChannel and provides:
* - Automatic message queuing during disconnections
* - Queue flushing on reconnection
* - Configurable queue size and message age limits
* - RTCDataChannel-compatible API
*
* @example
* ```typescript
* const channel = new DurableChannel('chat', connection, {
* maxQueueSize: 500,
* maxMessageAge: 30000
* });
*
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* channel.on('open', () => {
* channel.send('Hello!');
* });
*
* // Messages sent during disconnection are automatically queued
* channel.send('This will be queued if disconnected');
* ```
*/
export class DurableChannel extends EventEmitter<DurableChannelEvents> {
readonly label: string;
readonly config: DurableChannelConfig;
private _state: DurableChannelState;
private underlyingChannel?: RTCDataChannel;
private messageQueue: QueuedMessage[] = [];
private queueProcessing: boolean = false;
private _bufferedAmountLowThreshold: number = 0;
// Event handlers that need cleanup
private openHandler?: () => void;
private messageHandler?: (event: MessageEvent) => void;
private errorHandler?: (event: Event) => void;
private closeHandler?: () => void;
private bufferedAmountLowHandler?: () => void;
constructor(
label: string,
config?: DurableChannelConfig
) {
super();
this.label = label;
this.config = { ...DEFAULT_CONFIG, ...config };
this._state = DurableChannelState.CONNECTING;
}
/**
* Current channel state
*/
get readyState(): DurableChannelState {
return this._state;
}
/**
* Buffered amount from underlying channel (0 if no channel)
*/
get bufferedAmount(): number {
return this.underlyingChannel?.bufferedAmount ?? 0;
}
/**
* Buffered amount low threshold
*/
get bufferedAmountLowThreshold(): number {
return this._bufferedAmountLowThreshold;
}
set bufferedAmountLowThreshold(value: number) {
this._bufferedAmountLowThreshold = value;
if (this.underlyingChannel) {
this.underlyingChannel.bufferedAmountLowThreshold = value;
}
}
/**
* Send data through the channel
*
* If the channel is open, sends immediately. Otherwise, queues the message
* for delivery when the channel reconnects.
*
* @param data - Data to send
*/
send(data: string | Blob | ArrayBuffer | ArrayBufferView): void {
if (this._state === DurableChannelState.OPEN && this.underlyingChannel) {
// Channel is open - send immediately
try {
this.underlyingChannel.send(data as any);
} catch (error) {
// Send failed - queue the message
this.enqueueMessage(data);
this.emit('error', error as Error);
}
} else if (this._state !== DurableChannelState.CLOSED) {
// Channel is not open but not closed - queue the message
this.enqueueMessage(data);
} else {
// Channel is closed - throw error
throw new Error('Cannot send on closed channel');
}
}
/**
* Close the channel
*/
close(): void {
if (this._state === DurableChannelState.CLOSED ||
this._state === DurableChannelState.CLOSING) {
return;
}
this._state = DurableChannelState.CLOSING;
if (this.underlyingChannel) {
this.underlyingChannel.close();
}
this._state = DurableChannelState.CLOSED;
this.emit('close');
}
/**
* Attach to an underlying RTCDataChannel
*
* This is called when a WebRTC connection is established (or re-established).
* The channel will flush any queued messages and forward events.
*
* @param channel - RTCDataChannel to attach to
* @internal
*/
attachToChannel(channel: RTCDataChannel): void {
// Detach from any existing channel first
this.detachFromChannel();
this.underlyingChannel = channel;
// Set buffered amount low threshold
channel.bufferedAmountLowThreshold = this._bufferedAmountLowThreshold;
// Setup event handlers
this.openHandler = () => {
this._state = DurableChannelState.OPEN;
this.emit('open');
// Flush queued messages
this.flushQueue().catch(error => {
this.emit('error', error);
});
};
this.messageHandler = (event: MessageEvent) => {
this.emit('message', event.data);
};
this.errorHandler = (event: Event) => {
this.emit('error', new Error(`Channel error: ${event.type}`));
};
this.closeHandler = () => {
if (this._state !== DurableChannelState.CLOSING &&
this._state !== DurableChannelState.CLOSED) {
// Unexpected close - transition to connecting (will reconnect)
this._state = DurableChannelState.CONNECTING;
}
};
this.bufferedAmountLowHandler = () => {
this.emit('bufferedAmountLow');
};
// Attach handlers
channel.addEventListener('open', this.openHandler);
channel.addEventListener('message', this.messageHandler);
channel.addEventListener('error', this.errorHandler);
channel.addEventListener('close', this.closeHandler);
channel.addEventListener('bufferedamountlow', this.bufferedAmountLowHandler);
// If channel is already open, trigger open event
if (channel.readyState === 'open') {
this.openHandler();
} else if (channel.readyState === 'connecting') {
this._state = DurableChannelState.CONNECTING;
}
}
/**
* Detach from the underlying RTCDataChannel
*
* This is called when a WebRTC connection drops. The channel remains alive
* and continues queuing messages.
*
* @internal
*/
detachFromChannel(): void {
if (!this.underlyingChannel) {
return;
}
// Remove event listeners
if (this.openHandler) {
this.underlyingChannel.removeEventListener('open', this.openHandler);
}
if (this.messageHandler) {
this.underlyingChannel.removeEventListener('message', this.messageHandler);
}
if (this.errorHandler) {
this.underlyingChannel.removeEventListener('error', this.errorHandler);
}
if (this.closeHandler) {
this.underlyingChannel.removeEventListener('close', this.closeHandler);
}
if (this.bufferedAmountLowHandler) {
this.underlyingChannel.removeEventListener('bufferedamountlow', this.bufferedAmountLowHandler);
}
this.underlyingChannel = undefined;
this._state = DurableChannelState.CONNECTING;
}
/**
* Enqueue a message for later delivery
*/
private enqueueMessage(data: string | Blob | ArrayBuffer | ArrayBufferView): void {
// Prune old messages first
this.pruneOldMessages();
const message: QueuedMessage = {
data,
enqueuedAt: Date.now(),
id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
};
this.messageQueue.push(message);
// Handle overflow
const maxQueueSize = this.config.maxQueueSize ?? 1000;
if (this.messageQueue.length > maxQueueSize) {
const excess = this.messageQueue.length - maxQueueSize;
this.messageQueue.splice(0, excess);
this.emit('queueOverflow', excess);
console.warn(
`DurableChannel[${this.label}]: Dropped ${excess} messages due to queue overflow`
);
}
}
/**
* Flush all queued messages through the channel
*/
private async flushQueue(): Promise<void> {
if (this.queueProcessing || !this.underlyingChannel ||
this.underlyingChannel.readyState !== 'open') {
return;
}
this.queueProcessing = true;
try {
// Prune old messages before flushing
this.pruneOldMessages();
// Send all queued messages
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (!message) break;
try {
this.underlyingChannel.send(message.data as any);
} catch (error) {
// Send failed - re-queue message
this.messageQueue.unshift(message);
throw error;
}
// If buffer is getting full, wait for it to drain
if (this.underlyingChannel.bufferedAmount > 16 * 1024 * 1024) { // 16MB
await new Promise<void>((resolve) => {
const checkBuffer = () => {
if (!this.underlyingChannel ||
this.underlyingChannel.bufferedAmount < 8 * 1024 * 1024) {
resolve();
} else {
setTimeout(checkBuffer, 100);
}
};
checkBuffer();
});
}
}
} finally {
this.queueProcessing = false;
}
}
/**
* Remove messages older than maxMessageAge from the queue
*/
private pruneOldMessages(): void {
const maxMessageAge = this.config.maxMessageAge ?? 60000;
if (maxMessageAge === Infinity || maxMessageAge <= 0) {
return;
}
const now = Date.now();
const cutoff = now - maxMessageAge;
const originalLength = this.messageQueue.length;
this.messageQueue = this.messageQueue.filter(msg => msg.enqueuedAt >= cutoff);
const pruned = originalLength - this.messageQueue.length;
if (pruned > 0) {
console.warn(
`DurableChannel[${this.label}]: Pruned ${pruned} old messages (older than ${maxMessageAge}ms)`
);
}
}
/**
* Get the current queue size
*
* @internal
*/
getQueueSize(): number {
return this.messageQueue.length;
}
}

453
src/durable/connection.ts Normal file
View File

@@ -0,0 +1,453 @@
/**
* DurableConnection - WebRTC connection with automatic reconnection
*
* Manages the WebRTC peer lifecycle and automatically reconnects on
* connection drops with exponential backoff.
*/
import { EventEmitter } from '../event-emitter.js';
import RondevuPeer from '../peer/index.js';
import type { RondevuOffers } from '../offers.js';
import { DurableChannel } from './channel.js';
import { createReconnectionScheduler, type ReconnectionScheduler } from './reconnection.js';
import {
DurableConnectionState
} from './types.js';
import type {
DurableConnectionConfig,
DurableConnectionEvents,
ConnectionInfo
} from './types.js';
/**
* Default configuration for durable connections
*/
const DEFAULT_CONFIG: Required<DurableConnectionConfig> = {
maxReconnectAttempts: 10,
reconnectBackoffBase: 1000,
reconnectBackoffMax: 30000,
reconnectJitter: 0.2,
connectionTimeout: 30000,
maxQueueSize: 1000,
maxMessageAge: 60000,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
};
/**
* Durable WebRTC connection that automatically reconnects
*
* The DurableConnection manages the lifecycle of a WebRTC peer connection
* and provides:
* - Automatic reconnection with exponential backoff
* - Multiple durable channels that survive reconnections
* - Configurable retry limits and timeouts
* - High-level connection state events
*
* @example
* ```typescript
* const connection = new DurableConnection(
* offersApi,
* { username: 'alice', serviceFqn: 'chat@1.0.0' },
* { maxReconnectAttempts: 5 }
* );
*
* connection.on('connected', () => {
* console.log('Connected!');
* });
*
* connection.on('reconnecting', (attempt, max, delay) => {
* console.log(`Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`);
* });
*
* const channel = connection.createChannel('chat');
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* await connection.connect();
* ```
*/
export class DurableConnection extends EventEmitter<DurableConnectionEvents> {
readonly connectionId: string;
readonly config: Required<DurableConnectionConfig>;
readonly connectionInfo: ConnectionInfo;
private _state: DurableConnectionState;
private currentPeer?: RondevuPeer;
private channels: Map<string, DurableChannel> = new Map();
private reconnectionScheduler?: ReconnectionScheduler;
// Track peer event handlers for cleanup
private peerConnectedHandler?: () => void;
private peerDisconnectedHandler?: () => void;
private peerFailedHandler?: (error: Error) => void;
private peerDataChannelHandler?: (channel: RTCDataChannel) => void;
constructor(
private offersApi: RondevuOffers,
connectionInfo: ConnectionInfo,
config?: DurableConnectionConfig
) {
super();
this.connectionId = `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.config = { ...DEFAULT_CONFIG, ...config };
this.connectionInfo = connectionInfo;
this._state = DurableConnectionState.CONNECTING;
}
/**
* Current connection state
*/
getState(): DurableConnectionState {
return this._state;
}
/**
* Check if connection is currently connected
*/
isConnected(): boolean {
return this._state === DurableConnectionState.CONNECTED;
}
/**
* Create a durable channel on this connection
*
* The channel will be created on the current peer connection if available,
* otherwise it will be created when the connection is established.
*
* @param label - Channel label
* @param options - RTCDataChannel init options
* @returns DurableChannel instance
*/
createChannel(label: string, options?: RTCDataChannelInit): DurableChannel {
// Check if channel already exists
if (this.channels.has(label)) {
throw new Error(`Channel with label '${label}' already exists`);
}
// Create durable channel
const durableChannel = new DurableChannel(label, {
maxQueueSize: this.config.maxQueueSize,
maxMessageAge: this.config.maxMessageAge,
ordered: options?.ordered ?? true,
maxRetransmits: options?.maxRetransmits
});
this.channels.set(label, durableChannel);
// If we have a current peer, attach the channel
if (this.currentPeer && this._state === DurableConnectionState.CONNECTED) {
this.createAndAttachChannel(durableChannel, options);
}
return durableChannel;
}
/**
* Get an existing channel by label
*/
getChannel(label: string): DurableChannel | undefined {
return this.channels.get(label);
}
/**
* Establish the initial connection
*
* @returns Promise that resolves when connected
*/
async connect(): Promise<void> {
if (this._state !== DurableConnectionState.CONNECTING) {
throw new Error(`Cannot connect from state: ${this._state}`);
}
try {
await this.establishConnection();
} catch (error) {
this._state = DurableConnectionState.DISCONNECTED;
await this.handleDisconnection();
throw error;
}
}
/**
* Close the connection gracefully
*/
async close(): Promise<void> {
if (this._state === DurableConnectionState.CLOSED) {
return;
}
const previousState = this._state;
this._state = DurableConnectionState.CLOSED;
// Cancel any ongoing reconnection
if (this.reconnectionScheduler) {
this.reconnectionScheduler.cancel();
}
// Close all channels
for (const channel of this.channels.values()) {
channel.close();
}
// Close peer connection
if (this.currentPeer) {
await this.currentPeer.close();
this.currentPeer = undefined;
}
this.emit('state', this._state, previousState);
this.emit('closed');
}
/**
* Establish a WebRTC connection
*/
private async establishConnection(): Promise<void> {
// Create new peer
const peer = new RondevuPeer(this.offersApi, this.config.rtcConfig);
this.currentPeer = peer;
// Setup peer event handlers
this.setupPeerHandlers(peer);
// Determine connection method based on connection info
if (this.connectionInfo.uuid) {
// Connect by UUID
await this.connectByUuid(peer, this.connectionInfo.uuid);
} else if (this.connectionInfo.username && this.connectionInfo.serviceFqn) {
// Connect by username and service FQN
await this.connectByService(peer, this.connectionInfo.username, this.connectionInfo.serviceFqn);
} else {
throw new Error('Invalid connection info: must provide either uuid or (username + serviceFqn)');
}
// Wait for connection with timeout
await this.waitForConnection(peer);
// Connection established
this.transitionToConnected();
}
/**
* Connect to a service by UUID
*/
private async connectByUuid(peer: RondevuPeer, uuid: string): Promise<void> {
// Get service details
const response = await fetch(`${this.offersApi['baseUrl']}/services/${uuid}`);
if (!response.ok) {
throw new Error(`Service not found: ${uuid}`);
}
const service = await response.json();
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
secret: this.offersApi['credentials'].secret,
topics: []
});
}
/**
* Connect to a service by username and service FQN
*/
private async connectByService(peer: RondevuPeer, username: string, serviceFqn: string): Promise<void> {
// Query service to get UUID
const response = await fetch(`${this.offersApi['baseUrl']}/index/${username}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceFqn })
});
if (!response.ok) {
throw new Error(`Service not found: ${username}/${serviceFqn}`);
}
const { uuid } = await response.json();
// Connect by UUID
await this.connectByUuid(peer, uuid);
}
/**
* Wait for peer connection to establish
*/
private async waitForConnection(peer: RondevuPeer): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, this.config.connectionTimeout);
const onConnected = () => {
clearTimeout(timeout);
peer.off('connected', onConnected);
peer.off('failed', onFailed);
resolve();
};
const onFailed = (error: Error) => {
clearTimeout(timeout);
peer.off('connected', onConnected);
peer.off('failed', onFailed);
reject(error);
};
peer.on('connected', onConnected);
peer.on('failed', onFailed);
});
}
/**
* Setup event handlers for peer
*/
private setupPeerHandlers(peer: RondevuPeer): void {
this.peerConnectedHandler = () => {
// Connection established - will be handled by waitForConnection
};
this.peerDisconnectedHandler = () => {
if (this._state !== DurableConnectionState.CLOSED) {
this.handleDisconnection();
}
};
this.peerFailedHandler = (error: Error) => {
if (this._state !== DurableConnectionState.CLOSED) {
console.error('Peer connection failed:', error);
this.handleDisconnection();
}
};
this.peerDataChannelHandler = (channel: RTCDataChannel) => {
// Find or create durable channel
let durableChannel = this.channels.get(channel.label);
if (!durableChannel) {
// Auto-create channel for incoming data channels
durableChannel = new DurableChannel(channel.label, {
maxQueueSize: this.config.maxQueueSize,
maxMessageAge: this.config.maxMessageAge
});
this.channels.set(channel.label, durableChannel);
}
// Attach the received channel
durableChannel.attachToChannel(channel);
};
peer.on('connected', this.peerConnectedHandler);
peer.on('disconnected', this.peerDisconnectedHandler);
peer.on('failed', this.peerFailedHandler);
peer.on('datachannel', this.peerDataChannelHandler);
}
/**
* Transition to connected state
*/
private transitionToConnected(): void {
const previousState = this._state;
this._state = DurableConnectionState.CONNECTED;
// Reset reconnection scheduler if it exists
if (this.reconnectionScheduler) {
this.reconnectionScheduler.reset();
}
// Attach all channels to the new peer connection
for (const [label, channel] of this.channels) {
if (this.currentPeer) {
this.createAndAttachChannel(channel);
}
}
this.emit('state', this._state, previousState);
this.emit('connected');
}
/**
* Create underlying RTCDataChannel and attach to durable channel
*/
private createAndAttachChannel(
durableChannel: DurableChannel,
options?: RTCDataChannelInit
): void {
if (!this.currentPeer) {
return;
}
// Check if peer already has this channel (received via datachannel event)
// If not, create it
const senders = (this.currentPeer.pc as any).getSenders?.() || [];
const existingChannel = Array.from(senders as RTCRtpSender[])
.map((sender) => (sender as any).channel as RTCDataChannel)
.find(ch => ch && ch.label === durableChannel.label);
if (existingChannel) {
durableChannel.attachToChannel(existingChannel);
} else {
// Create new channel on peer
const rtcChannel = this.currentPeer.createDataChannel(
durableChannel.label,
options
);
durableChannel.attachToChannel(rtcChannel);
}
}
/**
* Handle connection disconnection
*/
private async handleDisconnection(): Promise<void> {
if (this._state === DurableConnectionState.CLOSED ||
this._state === DurableConnectionState.FAILED) {
return;
}
const previousState = this._state;
this._state = DurableConnectionState.RECONNECTING;
this.emit('state', this._state, previousState);
this.emit('disconnected');
// Detach all channels (but keep them alive)
for (const channel of this.channels.values()) {
channel.detachFromChannel();
}
// Close old peer
if (this.currentPeer) {
await this.currentPeer.close();
this.currentPeer = undefined;
}
// Create or use existing reconnection scheduler
if (!this.reconnectionScheduler) {
this.reconnectionScheduler = createReconnectionScheduler({
maxAttempts: this.config.maxReconnectAttempts,
backoffBase: this.config.reconnectBackoffBase,
backoffMax: this.config.reconnectBackoffMax,
jitter: this.config.reconnectJitter,
onReconnect: async () => {
await this.establishConnection();
},
onMaxAttemptsExceeded: (error) => {
const prevState = this._state;
this._state = DurableConnectionState.FAILED;
this.emit('state', this._state, prevState);
this.emit('failed', error, true);
},
onBeforeAttempt: (attempt, max, delay) => {
this.emit('reconnecting', attempt, max, delay);
}
});
}
// Schedule reconnection
this.reconnectionScheduler.schedule();
}
}

200
src/durable/reconnection.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* Reconnection utilities for durable connections
*
* This module provides utilities for managing reconnection logic with
* exponential backoff and jitter.
*/
/**
* Calculate exponential backoff delay with jitter
*
* @param attempt - Current attempt number (0-indexed)
* @param base - Base delay in milliseconds
* @param max - Maximum delay in milliseconds
* @param jitter - Jitter factor (0-1), e.g., 0.2 for ±20%
* @returns Delay in milliseconds with jitter applied
*
* @example
* ```typescript
* calculateBackoff(0, 1000, 30000, 0.2) // ~1000ms ± 20%
* calculateBackoff(1, 1000, 30000, 0.2) // ~2000ms ± 20%
* calculateBackoff(2, 1000, 30000, 0.2) // ~4000ms ± 20%
* calculateBackoff(5, 1000, 30000, 0.2) // ~30000ms ± 20% (capped at max)
* ```
*/
export function calculateBackoff(
attempt: number,
base: number,
max: number,
jitter: number
): number {
// Calculate exponential delay: base * 2^attempt
const exponential = base * Math.pow(2, attempt);
// Cap at maximum
const capped = Math.min(exponential, max);
// Apply jitter: ± (jitter * capped)
const jitterAmount = capped * jitter;
const randomJitter = (Math.random() * 2 - 1) * jitterAmount;
// Return delay with jitter, ensuring it's not negative
return Math.max(0, capped + randomJitter);
}
/**
* Configuration for reconnection scheduler
*/
export interface ReconnectionSchedulerConfig {
/** Maximum number of reconnection attempts */
maxAttempts: number;
/** Base delay for exponential backoff */
backoffBase: number;
/** Maximum delay between attempts */
backoffMax: number;
/** Jitter factor for randomizing delays */
jitter: number;
/** Callback invoked for each reconnection attempt */
onReconnect: () => Promise<void>;
/** Callback invoked when max attempts exceeded */
onMaxAttemptsExceeded: (error: Error) => void;
/** Optional callback invoked before each attempt */
onBeforeAttempt?: (attempt: number, maxAttempts: number, delay: number) => void;
}
/**
* Reconnection scheduler state
*/
export interface ReconnectionScheduler {
/** Current attempt number */
attempt: number;
/** Whether scheduler is active */
active: boolean;
/** Schedule next reconnection attempt */
schedule: () => void;
/** Cancel scheduled reconnection */
cancel: () => void;
/** Reset attempt counter */
reset: () => void;
}
/**
* Create a reconnection scheduler
*
* @param config - Scheduler configuration
* @returns Reconnection scheduler instance
*
* @example
* ```typescript
* const scheduler = createReconnectionScheduler({
* maxAttempts: 10,
* backoffBase: 1000,
* backoffMax: 30000,
* jitter: 0.2,
* onReconnect: async () => {
* await connect();
* },
* onMaxAttemptsExceeded: (error) => {
* console.error('Failed to reconnect:', error);
* },
* onBeforeAttempt: (attempt, max, delay) => {
* console.log(`Reconnecting in ${delay}ms (${attempt}/${max})...`);
* }
* });
*
* // Start reconnection
* scheduler.schedule();
*
* // Cancel reconnection
* scheduler.cancel();
* ```
*/
export function createReconnectionScheduler(
config: ReconnectionSchedulerConfig
): ReconnectionScheduler {
let attempt = 0;
let active = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
// Cancel any existing timer
if (timer) {
clearTimeout(timer);
timer = undefined;
}
// Check if max attempts exceeded
if (attempt >= config.maxAttempts) {
active = false;
config.onMaxAttemptsExceeded(
new Error(`Max reconnection attempts exceeded (${config.maxAttempts})`)
);
return;
}
// Calculate delay
const delay = calculateBackoff(
attempt,
config.backoffBase,
config.backoffMax,
config.jitter
);
// Notify before attempt
if (config.onBeforeAttempt) {
config.onBeforeAttempt(attempt + 1, config.maxAttempts, delay);
}
// Mark as active
active = true;
// Schedule reconnection
timer = setTimeout(async () => {
attempt++;
try {
await config.onReconnect();
// Success - reset scheduler
attempt = 0;
active = false;
} catch (error) {
// Failure - schedule next attempt
schedule();
}
}, delay);
};
const cancel = () => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
active = false;
};
const reset = () => {
cancel();
attempt = 0;
};
return {
get attempt() {
return attempt;
},
get active() {
return active;
},
schedule,
cancel,
reset
};
}

329
src/durable/service.ts Normal file
View File

@@ -0,0 +1,329 @@
/**
* DurableService - Service with automatic TTL refresh
*
* Manages service publishing with automatic reconnection for incoming
* connections and TTL auto-refresh to prevent expiration.
*/
import { EventEmitter } from '../event-emitter.js';
import { ServicePool, type PoolStatus } from '../service-pool.js';
import type { RondevuOffers } from '../offers.js';
import { DurableChannel } from './channel.js';
import type {
DurableServiceConfig,
DurableServiceEvents,
ServiceInfo
} from './types.js';
/**
* Connection handler callback
*/
export type ConnectionHandler = (
channel: DurableChannel,
connectionId: string
) => void | Promise<void>;
/**
* Default configuration for durable services
*/
const DEFAULT_CONFIG = {
isPublic: false,
ttlRefreshMargin: 0.2,
poolSize: 1,
pollingInterval: 2000,
maxReconnectAttempts: 10,
reconnectBackoffBase: 1000,
reconnectBackoffMax: 30000,
reconnectJitter: 0.2,
connectionTimeout: 30000,
maxQueueSize: 1000,
maxMessageAge: 60000,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
};
/**
* Durable service that automatically refreshes TTL and handles reconnections
*
* The DurableService manages service publishing and provides:
* - Automatic TTL refresh before expiration
* - Durable connections for incoming peers
* - Connection pooling for multiple simultaneous connections
* - High-level connection lifecycle events
*
* @example
* ```typescript
* const service = new DurableService(
* offersApi,
* (channel, connectionId) => {
* channel.on('message', (data) => {
* console.log(`Message from ${connectionId}:`, data);
* channel.send(`Echo: ${data}`);
* });
* },
* {
* username: 'alice',
* privateKey: keypair.privateKey,
* serviceFqn: 'chat@1.0.0',
* poolSize: 10
* }
* );
*
* service.on('published', (serviceId, uuid) => {
* console.log(`Service published: ${uuid}`);
* });
*
* service.on('connection', (connectionId) => {
* console.log(`New connection: ${connectionId}`);
* });
*
* await service.start();
* ```
*/
export class DurableService extends EventEmitter<DurableServiceEvents> {
readonly config: Required<DurableServiceConfig>;
private serviceId?: string;
private uuid?: string;
private expiresAt?: number;
private ttlRefreshTimer?: ReturnType<typeof setTimeout>;
private servicePool?: ServicePool;
private activeChannels: Map<string, DurableChannel> = new Map();
constructor(
private offersApi: RondevuOffers,
private baseUrl: string,
private credentials: { peerId: string; secret: string },
private handler: ConnectionHandler,
config: DurableServiceConfig
) {
super();
this.config = { ...DEFAULT_CONFIG, ...config } as Required<DurableServiceConfig>;
}
/**
* Start the service
*
* Publishes the service and begins accepting connections.
*
* @returns Service information
*/
async start(): Promise<ServiceInfo> {
if (this.servicePool) {
throw new Error('Service already started');
}
// Create and start service pool
this.servicePool = new ServicePool(
this.baseUrl,
this.credentials,
{
username: this.config.username,
privateKey: this.config.privateKey,
serviceFqn: this.config.serviceFqn,
rtcConfig: this.config.rtcConfig,
isPublic: this.config.isPublic,
metadata: this.config.metadata,
ttl: this.config.ttl,
poolSize: this.config.poolSize,
pollingInterval: this.config.pollingInterval,
handler: (channel, peer, connectionId) => {
this.handleNewConnection(channel, connectionId);
},
onPoolStatus: (status) => {
// Could emit pool status event if needed
},
onError: (error, context) => {
this.emit('error', error, context);
}
}
);
const handle = await this.servicePool.start();
// Store service info
this.serviceId = handle.serviceId;
this.uuid = handle.uuid;
this.expiresAt = Date.now() + (this.config.ttl || 300000); // Default 5 minutes
this.emit('published', this.serviceId, this.uuid);
// Schedule TTL refresh
this.scheduleRefresh();
return {
serviceId: this.serviceId,
uuid: this.uuid,
expiresAt: this.expiresAt
};
}
/**
* Stop the service
*
* Unpublishes the service and closes all active connections.
*/
async stop(): Promise<void> {
// Cancel TTL refresh
if (this.ttlRefreshTimer) {
clearTimeout(this.ttlRefreshTimer);
this.ttlRefreshTimer = undefined;
}
// Close all active channels
for (const channel of this.activeChannels.values()) {
channel.close();
}
this.activeChannels.clear();
// Stop service pool
if (this.servicePool) {
await this.servicePool.stop();
this.servicePool = undefined;
}
this.emit('closed');
}
/**
* Get list of active connection IDs
*/
getActiveConnections(): string[] {
return Array.from(this.activeChannels.keys());
}
/**
* Get service information
*/
getServiceInfo(): ServiceInfo | null {
if (!this.serviceId || !this.uuid || !this.expiresAt) {
return null;
}
return {
serviceId: this.serviceId,
uuid: this.uuid,
expiresAt: this.expiresAt
};
}
/**
* Schedule TTL refresh
*/
private scheduleRefresh(): void {
if (!this.expiresAt || !this.config.ttl) {
return;
}
// Cancel existing timer
if (this.ttlRefreshTimer) {
clearTimeout(this.ttlRefreshTimer);
}
// Calculate refresh time (default: refresh at 80% of TTL)
const timeUntilExpiry = this.expiresAt - Date.now();
const refreshMargin = timeUntilExpiry * this.config.ttlRefreshMargin;
const refreshTime = Math.max(0, timeUntilExpiry - refreshMargin);
// Schedule refresh
this.ttlRefreshTimer = setTimeout(() => {
this.refreshServiceTTL().catch(error => {
this.emit('error', error, 'ttl-refresh');
// Retry after short delay
setTimeout(() => this.scheduleRefresh(), 5000);
});
}, refreshTime);
}
/**
* Refresh service TTL
*/
private async refreshServiceTTL(): Promise<void> {
if (!this.serviceId || !this.uuid) {
return;
}
// Delete old service
await this.servicePool?.stop();
// Recreate service pool (this republishes the service)
this.servicePool = new ServicePool(
this.baseUrl,
this.credentials,
{
username: this.config.username,
privateKey: this.config.privateKey,
serviceFqn: this.config.serviceFqn,
rtcConfig: this.config.rtcConfig,
isPublic: this.config.isPublic,
metadata: this.config.metadata,
ttl: this.config.ttl,
poolSize: this.config.poolSize,
pollingInterval: this.config.pollingInterval,
handler: (channel, peer, connectionId) => {
this.handleNewConnection(channel, connectionId);
},
onPoolStatus: (status) => {
// Could emit pool status event if needed
},
onError: (error, context) => {
this.emit('error', error, context);
}
}
);
const handle = await this.servicePool.start();
// Update service info
this.serviceId = handle.serviceId;
this.uuid = handle.uuid;
this.expiresAt = Date.now() + (this.config.ttl || 300000);
this.emit('ttl-refreshed', this.expiresAt);
// Schedule next refresh
this.scheduleRefresh();
}
/**
* Handle new incoming connection
*/
private handleNewConnection(channel: RTCDataChannel, connectionId: string): void {
// Create durable channel
const durableChannel = new DurableChannel(channel.label, {
maxQueueSize: this.config.maxQueueSize,
maxMessageAge: this.config.maxMessageAge
});
// Attach to underlying channel
durableChannel.attachToChannel(channel);
// Track channel
this.activeChannels.set(connectionId, durableChannel);
// Setup cleanup on close
durableChannel.on('close', () => {
this.activeChannels.delete(connectionId);
this.emit('disconnection', connectionId);
});
// Emit connection event
this.emit('connection', connectionId);
// Invoke user handler
try {
const result = this.handler(durableChannel, connectionId);
if (result && typeof result.then === 'function') {
result.catch(error => {
this.emit('error', error, 'handler');
});
}
} catch (error) {
this.emit('error', error as Error, 'handler');
}
}
}

184
src/durable/types.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Type definitions for durable WebRTC connections
*
* This module defines all interfaces, enums, and types used by the durable
* connection system for automatic reconnection and message queuing.
*/
/**
* Connection state enum
*/
export enum DurableConnectionState {
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
DISCONNECTED = 'disconnected',
FAILED = 'failed',
CLOSED = 'closed'
}
/**
* Channel state enum
*/
export enum DurableChannelState {
CONNECTING = 'connecting',
OPEN = 'open',
CLOSING = 'closing',
CLOSED = 'closed'
}
/**
* Configuration for durable connections
*/
export interface DurableConnectionConfig {
/** Maximum number of reconnection attempts (default: 10) */
maxReconnectAttempts?: number;
/** Base delay for exponential backoff in milliseconds (default: 1000) */
reconnectBackoffBase?: number;
/** Maximum delay between reconnection attempts in milliseconds (default: 30000) */
reconnectBackoffMax?: number;
/** Jitter factor for randomizing reconnection delays (default: 0.2 = ±20%) */
reconnectJitter?: number;
/** Timeout for initial connection attempt in milliseconds (default: 30000) */
connectionTimeout?: number;
/** Maximum number of messages to queue during disconnection (default: 1000) */
maxQueueSize?: number;
/** Maximum age of queued messages in milliseconds (default: 60000) */
maxMessageAge?: number;
/** WebRTC configuration */
rtcConfig?: RTCConfiguration;
}
/**
* Configuration for durable channels
*/
export interface DurableChannelConfig {
/** Maximum number of messages to queue (default: 1000) */
maxQueueSize?: number;
/** Maximum age of queued messages in milliseconds (default: 60000) */
maxMessageAge?: number;
/** Whether messages should be delivered in order (default: true) */
ordered?: boolean;
/** Maximum retransmits for unordered channels (default: undefined) */
maxRetransmits?: number;
}
/**
* Configuration for durable services
*/
export interface DurableServiceConfig extends DurableConnectionConfig {
/** Username that owns the service */
username: string;
/** Private key for signing service operations */
privateKey: string;
/** Fully qualified service name (e.g., com.example.chat@1.0.0) */
serviceFqn: string;
/** Whether the service is publicly discoverable (default: false) */
isPublic?: boolean;
/** Optional metadata for the service */
metadata?: Record<string, any>;
/** Time-to-live for service in milliseconds (default: server default) */
ttl?: number;
/** Margin before TTL expiry to trigger refresh (default: 0.2 = refresh at 80%) */
ttlRefreshMargin?: number;
/** Number of simultaneous open offers to maintain (default: 1) */
poolSize?: number;
/** Polling interval for checking answers in milliseconds (default: 2000) */
pollingInterval?: number;
}
/**
* Queued message structure
*/
export interface QueuedMessage {
/** Message data */
data: string | Blob | ArrayBuffer | ArrayBufferView;
/** Timestamp when message was enqueued */
enqueuedAt: number;
/** Unique message ID */
id: string;
}
/**
* Event type map for DurableConnection
*/
export interface DurableConnectionEvents extends Record<string, (...args: any[]) => void> {
'state': (state: DurableConnectionState, previousState: DurableConnectionState) => void;
'connected': () => void;
'reconnecting': (attempt: number, maxAttempts: number, nextRetryIn: number) => void;
'disconnected': () => void;
'failed': (error: Error, permanent: boolean) => void;
'closed': () => void;
}
/**
* Event type map for DurableChannel
*/
export interface DurableChannelEvents extends Record<string, (...args: any[]) => void> {
'open': () => void;
'message': (data: any) => void;
'error': (error: Error) => void;
'close': () => void;
'bufferedAmountLow': () => void;
'queueOverflow': (droppedCount: number) => void;
}
/**
* Event type map for DurableService
*/
export interface DurableServiceEvents extends Record<string, (...args: any[]) => void> {
'published': (serviceId: string, uuid: string) => void;
'connection': (connectionId: string) => void;
'disconnection': (connectionId: string) => void;
'ttl-refreshed': (expiresAt: number) => void;
'error': (error: Error, context: string) => void;
'closed': () => void;
}
/**
* Information about a durable connection
*/
export interface ConnectionInfo {
/** Username (for username-based connections) */
username?: string;
/** Service FQN (for service-based connections) */
serviceFqn?: string;
/** UUID (for UUID-based connections) */
uuid?: string;
}
/**
* Service information returned when service is published
*/
export interface ServiceInfo {
/** Service ID */
serviceId: string;
/** Service UUID for discovery */
uuid: string;
/** Expiration timestamp */
expiresAt: number;
}

View File

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

View File

@@ -1,41 +1,36 @@
/** /**
* @xtr-dev/rondevu-client * @xtr-dev/rondevu-client
* WebRTC peer signaling and discovery client * WebRTC peer signaling and discovery client with durable connections
*/ */
// Export main WebRTC client class // Export main client class
export { Rondevu } from './rondevu'; export { Rondevu } from './rondevu.js';
export type { RondevuOptions } from './rondevu.js';
// Export connection class // Export authentication
export { RondevuConnection } from './connection'; export { RondevuAuth } from './auth.js';
export type { Credentials, FetchFunction } from './auth.js';
// Export low-level signaling client (for advanced usage) // Export username API
export { RondevuClient } from './client'; export { RondevuUsername } from './usernames.js';
export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js';
// Export all types // Export durable connection APIs
export { DurableConnection } from './durable/connection.js';
export { DurableChannel } from './durable/channel.js';
export { DurableService } from './durable/service.js';
// Export durable connection types
export type { export type {
// WebRTC types DurableConnectionState,
RondevuOptions, DurableChannelState,
JoinOptions, DurableConnectionConfig,
ConnectionRole, DurableChannelConfig,
RondevuConnectionParams, DurableServiceConfig,
RondevuConnectionEvents, QueuedMessage,
// Signaling types DurableConnectionEvents,
Side, DurableChannelEvents,
Session, DurableServiceEvents,
TopicInfo, ConnectionInfo,
Pagination, ServiceInfo
ListTopicsResponse, } from './durable/types.js';
ListSessionsResponse,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
PollResponse,
HealthResponse,
ErrorResponse,
RondevuClientOptions,
} from './types';

205
src/offer-pool.ts Normal file
View File

@@ -0,0 +1,205 @@
import { RondevuOffers, Offer } from './offers.js';
/**
* Represents an offer that has been answered
*/
export interface AnsweredOffer {
offerId: string;
answererId: string;
sdp: string; // Answer SDP
peerConnection: RTCPeerConnection; // Original peer connection
dataChannel?: RTCDataChannel; // Data channel created with offer
answeredAt: number;
}
/**
* Configuration options for the offer pool
*/
export interface OfferPoolOptions {
/** Number of simultaneous open offers to maintain */
poolSize: number;
/** Polling interval in milliseconds (default: 2000ms) */
pollingInterval?: number;
/** Callback invoked when an offer is answered */
onAnswered: (answer: AnsweredOffer) => Promise<void>;
/** Callback to create new offers when refilling the pool */
onRefill: (count: number) => Promise<{ offers: Offer[], peerConnections: RTCPeerConnection[], dataChannels: RTCDataChannel[] }>;
/** Error handler for pool operations */
onError: (error: Error, context: string) => void;
}
/**
* Manages a pool of offers with automatic polling and refill
*
* The OfferPool maintains a configurable number of simultaneous offers,
* polls for answers periodically, and automatically refills the pool
* when offers are consumed.
*/
export class OfferPool {
private offers: Map<string, Offer> = new Map();
private peerConnections: Map<string, RTCPeerConnection> = new Map();
private dataChannels: Map<string, RTCDataChannel> = new Map();
private polling: boolean = false;
private pollingTimer?: ReturnType<typeof setInterval>;
private lastPollTime: number = 0;
private readonly pollingInterval: number;
constructor(
private offersApi: RondevuOffers,
private options: OfferPoolOptions
) {
this.pollingInterval = options.pollingInterval || 2000;
}
/**
* Add offers to the pool with their peer connections and data channels
*/
async addOffers(offers: Offer[], peerConnections?: RTCPeerConnection[], dataChannels?: RTCDataChannel[]): Promise<void> {
for (let i = 0; i < offers.length; i++) {
const offer = offers[i];
this.offers.set(offer.id, offer);
if (peerConnections && peerConnections[i]) {
this.peerConnections.set(offer.id, peerConnections[i]);
}
if (dataChannels && dataChannels[i]) {
this.dataChannels.set(offer.id, dataChannels[i]);
}
}
}
/**
* Start polling for answers
*/
async start(): Promise<void> {
if (this.polling) {
return;
}
this.polling = true;
// Do an immediate poll
await this.poll().catch((error) => {
this.options.onError(error, 'initial-poll');
});
// Start polling interval
this.pollingTimer = setInterval(async () => {
if (this.polling) {
await this.poll().catch((error) => {
this.options.onError(error, 'poll');
});
}
}, this.pollingInterval);
}
/**
* Stop polling for answers
*/
async stop(): Promise<void> {
this.polling = false;
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = undefined;
}
}
/**
* Poll for answers and refill the pool if needed
*/
private async poll(): Promise<void> {
try {
// Get all answers from server
const answers = await this.offersApi.getAnswers();
// Filter for our pool's offers
const myAnswers = answers.filter(a => this.offers.has(a.offerId));
// Process each answer
for (const answer of myAnswers) {
// Get the original offer, peer connection, and data channel
const offer = this.offers.get(answer.offerId);
const pc = this.peerConnections.get(answer.offerId);
const channel = this.dataChannels.get(answer.offerId);
if (!offer || !pc) {
continue; // Offer or peer connection already consumed, skip
}
// Remove from pool BEFORE processing to prevent duplicate processing
this.offers.delete(answer.offerId);
this.peerConnections.delete(answer.offerId);
this.dataChannels.delete(answer.offerId);
// Notify ServicePool with answer, original peer connection, and data channel
await this.options.onAnswered({
offerId: answer.offerId,
answererId: answer.answererId,
sdp: answer.sdp,
peerConnection: pc,
dataChannel: channel,
answeredAt: answer.answeredAt
});
}
// Immediate refill if below pool size
if (this.offers.size < this.options.poolSize) {
const needed = this.options.poolSize - this.offers.size;
try {
const result = await this.options.onRefill(needed);
await this.addOffers(result.offers, result.peerConnections, result.dataChannels);
} catch (refillError) {
this.options.onError(
refillError as Error,
'refill'
);
}
}
this.lastPollTime = Date.now();
} catch (error) {
// Don't crash the pool on errors - let error handler deal with it
this.options.onError(error as Error, 'poll');
}
}
/**
* Get the current number of active offers in the pool
*/
getActiveOfferCount(): number {
return this.offers.size;
}
/**
* Get all active offer IDs
*/
getActiveOfferIds(): string[] {
return Array.from(this.offers.keys());
}
/**
* Get all active peer connections
*/
getActivePeerConnections(): RTCPeerConnection[] {
return Array.from(this.peerConnections.values());
}
/**
* Get the last poll timestamp
*/
getLastPollTime(): number {
return this.lastPollTime;
}
/**
* Check if the pool is currently polling
*/
isPolling(): boolean {
return this.polling;
}
}

321
src/offers.ts Normal file
View File

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

View File

@@ -0,0 +1,49 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Answering an offer and sending to server
*/
export class AnsweringState extends PeerState {
constructor(peer: RondevuPeer) {
super(peer);
}
get name() { return 'answering'; }
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
try {
this.peer.role = 'answerer';
this.peer.offerId = offerId;
// Set remote description
await this.peer.pc.setRemoteDescription({
type: 'offer',
sdp: offerSdp
});
// Create answer
const answer = await this.peer.pc.createAnswer();
// Send answer to server BEFORE setLocalDescription
// This registers us as the answerer so ICE candidates will be accepted
await this.peer.offersApi.answer(offerId, answer.sdp!, options.secret);
// Enable trickle ICE - set up handler before ICE gathering starts
this.setupIceCandidateHandler();
// Set local description - ICE gathering starts here
// Server already knows we're the answerer, so candidates will be accepted
await this.peer.pc.setLocalDescription(answer);
// Transition to exchanging ICE
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
this.peer.setState(new ExchangingIceState(this.peer, offerId, options));
} catch (error) {
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(this.peer, error as Error));
throw error;
}
}
}

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

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

View File

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

View File

@@ -0,0 +1,57 @@
import { PeerState } from './state.js';
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Creating offer and sending to server
*/
export class CreatingOfferState extends PeerState {
constructor(peer: RondevuPeer, private options: PeerOptions) {
super(peer);
}
get name() { return 'creating-offer'; }
async createOffer(options: PeerOptions): Promise<string> {
try {
this.peer.role = 'offerer';
// Create data channel if requested
if (options.createDataChannel !== false) {
const channel = this.peer.pc.createDataChannel(
options.dataChannelLabel || 'data'
);
this.peer.emitEvent('datachannel', channel);
}
// Enable trickle ICE - set up handler before ICE gathering starts
// Handler will check this.peer.offerId before sending
this.setupIceCandidateHandler();
// Create WebRTC offer
const offer = await this.peer.pc.createOffer();
await this.peer.pc.setLocalDescription(offer); // ICE gathering starts here
// Send offer to server immediately (don't wait for ICE)
const offers = await this.peer.offersApi.create([{
sdp: offer.sdp!,
topics: options.topics,
ttl: options.ttl || 300000,
secret: options.secret
}]);
const offerId = offers[0].id;
this.peer.offerId = offerId; // Now handler can send candidates
// Transition to waiting for answer
const { WaitingForAnswerState } = await import('./waiting-for-answer-state.js');
this.peer.setState(new WaitingForAnswerState(this.peer, offerId, options));
return offerId;
} catch (error) {
const { FailedState } = await import('./failed-state.js');
this.peer.setState(new FailedState(this.peer, error as Error));
throw error;
}
}
}

View File

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

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

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

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

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

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

@@ -0,0 +1,237 @@
import { RondevuOffers } from '../offers.js';
import { EventEmitter } from '../event-emitter.js';
import type { PeerOptions, PeerEvents } from './types.js';
import { PeerState } from './state.js';
import { IdleState } from './idle-state.js';
import { CreatingOfferState } from './creating-offer-state.js';
import { WaitingForAnswerState } from './waiting-for-answer-state.js';
import { AnsweringState } from './answering-state.js';
import { ExchangingIceState } from './exchanging-ice-state.js';
import { ConnectedState } from './connected-state.js';
import { FailedState } from './failed-state.js';
import { ClosedState } from './closed-state.js';
// Re-export types for external consumers
export type { PeerTimeouts, PeerOptions, PeerEvents } from './types.js';
/**
* High-level WebRTC peer connection manager with state-based lifecycle
* Handles offer/answer exchange, ICE candidates, timeouts, and error recovery
*/
export default class RondevuPeer extends EventEmitter<PeerEvents> {
pc: RTCPeerConnection;
offersApi: RondevuOffers;
offerId?: string;
role?: 'offerer' | 'answerer';
// WebRTC polyfills for Node.js compatibility
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
private _state: PeerState;
// Event handler references for cleanup
private connectionStateChangeHandler?: () => void;
private dataChannelHandler?: (event: RTCDataChannelEvent) => void;
private trackHandler?: (event: RTCTrackEvent) => void;
private iceCandidateErrorHandler?: (event: Event) => void;
/**
* Current connection state name
*/
get stateName(): string {
return this._state.name;
}
/**
* Current state object (internal use)
*/
get state(): PeerState {
return this._state;
}
/**
* RTCPeerConnection state
*/
get connectionState(): RTCPeerConnectionState {
return this.pc.connectionState;
}
constructor(
offersApi: RondevuOffers,
rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
},
existingPeerConnection?: RTCPeerConnection,
rtcPeerConnection?: typeof RTCPeerConnection,
rtcSessionDescription?: typeof RTCSessionDescription,
rtcIceCandidate?: typeof RTCIceCandidate
) {
super();
this.offersApi = offersApi;
// Use provided polyfills or fall back to globals
this.RTCPeerConnection = rtcPeerConnection || (typeof globalThis.RTCPeerConnection !== 'undefined'
? globalThis.RTCPeerConnection
: (() => {
throw new Error('RTCPeerConnection is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCSessionDescription = rtcSessionDescription || (typeof globalThis.RTCSessionDescription !== 'undefined'
? globalThis.RTCSessionDescription
: (() => {
throw new Error('RTCSessionDescription is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCIceCandidate = rtcIceCandidate || (typeof globalThis.RTCIceCandidate !== 'undefined'
? globalThis.RTCIceCandidate
: (() => {
throw new Error('RTCIceCandidate is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
// Use existing peer connection if provided, otherwise create new one
this.pc = existingPeerConnection || new this.RTCPeerConnection(rtcConfig);
this._state = new IdleState(this);
this.setupPeerConnection();
}
/**
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.connectionStateChangeHandler = () => {
console.log(`🔌 Connection state changed: ${this.pc.connectionState}`);
switch (this.pc.connectionState) {
case 'connected':
console.log('✅ WebRTC connection established');
this.setState(new ConnectedState(this));
this.emitEvent('connected');
break;
case 'disconnected':
console.log('⚠️ WebRTC connection disconnected');
this.emitEvent('disconnected');
break;
case 'failed':
console.log('❌ WebRTC connection failed');
this.setState(new FailedState(this, new Error('Connection failed')));
break;
case 'closed':
console.log('🔒 WebRTC connection closed');
this.setState(new ClosedState(this));
this.emitEvent('disconnected');
break;
}
};
this.pc.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
// Add ICE connection state logging
const iceConnectionStateHandler = () => {
console.log(`🧊 ICE connection state: ${this.pc.iceConnectionState}`);
};
this.pc.addEventListener('iceconnectionstatechange', iceConnectionStateHandler);
// Add ICE gathering state logging
const iceGatheringStateHandler = () => {
console.log(`🔍 ICE gathering state: ${this.pc.iceGatheringState}`);
};
this.pc.addEventListener('icegatheringstatechange', iceGatheringStateHandler);
this.dataChannelHandler = (event: RTCDataChannelEvent) => {
this.emitEvent('datachannel', event.channel);
};
this.pc.addEventListener('datachannel', this.dataChannelHandler);
this.trackHandler = (event: RTCTrackEvent) => {
this.emitEvent('track', event);
};
this.pc.addEventListener('track', this.trackHandler);
this.iceCandidateErrorHandler = (event: Event) => {
const iceError = event as RTCPeerConnectionIceErrorEvent;
console.error(`❌ ICE candidate error: ${iceError.errorText || 'Unknown error'}`, {
errorCode: iceError.errorCode,
url: iceError.url,
address: iceError.address,
port: iceError.port
});
};
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
/**
* Set new state and emit state change event
*/
setState(newState: PeerState): void {
this._state.cleanup();
this._state = newState;
this.emitEvent('state', newState.name);
}
/**
* Emit event (exposed for PeerState classes)
* @internal
*/
emitEvent<K extends keyof PeerEvents>(
event: K,
...args: Parameters<PeerEvents[K]>
): void {
this.emit(event, ...args);
}
/**
* Create an offer and advertise on topics
*/
async createOffer(options: PeerOptions): Promise<string> {
return this._state.createOffer(options);
}
/**
* Answer an existing offer
*/
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
return this._state.answer(offerId, offerSdp, options);
}
/**
* Add a media track to the connection
*/
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
return this.pc.addTrack(track, ...streams);
}
/**
* Create a data channel for sending and receiving arbitrary data
* This should typically be called by the offerer before creating the offer
* The answerer will receive the channel via the 'datachannel' event
*/
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
return this.pc.createDataChannel(label, options);
}
/**
* Close the connection and clean up
*/
async close(): Promise<void> {
// Remove RTCPeerConnection event listeners
if (this.connectionStateChangeHandler) {
this.pc.removeEventListener('connectionstatechange', this.connectionStateChangeHandler);
}
if (this.dataChannelHandler) {
this.pc.removeEventListener('datachannel', this.dataChannelHandler);
}
if (this.trackHandler) {
this.pc.removeEventListener('track', this.trackHandler);
}
if (this.iceCandidateErrorHandler) {
this.pc.removeEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
await this._state.close();
this.removeAllListeners();
}
}

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

@@ -0,0 +1,73 @@
import type { PeerOptions } from './types.js';
import type RondevuPeer from './index.js';
/**
* Base class for peer connection states
* Implements the State pattern for managing WebRTC connection lifecycle
*/
export abstract class PeerState {
protected iceCandidateHandler?: (event: RTCPeerConnectionIceEvent) => void;
constructor(protected peer: RondevuPeer) {}
abstract get name(): string;
async createOffer(options: PeerOptions): Promise<string> {
throw new Error(`Cannot create offer in ${this.name} state`);
}
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
throw new Error(`Cannot answer in ${this.name} state`);
}
async handleAnswer(sdp: string): Promise<void> {
throw new Error(`Cannot handle answer in ${this.name} state`);
}
async handleIceCandidate(candidate: any): Promise<void> {
// ICE candidates can arrive in multiple states, so default is to add them
if (this.peer.pc.remoteDescription) {
await this.peer.pc.addIceCandidate(new this.peer.RTCIceCandidate(candidate));
}
}
/**
* Setup trickle ICE candidate handler
* Sends local ICE candidates to server as they are discovered
*/
protected setupIceCandidateHandler(): void {
this.iceCandidateHandler = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && this.peer.offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Generated ${type} ICE candidate:`, candidateData.candidate);
try {
await this.peer.offersApi.addIceCandidates(this.peer.offerId, [candidateData]);
console.log(`✅ Sent ${type} ICE candidate to server`);
} catch (err) {
console.error(`❌ Error sending ${type} ICE candidate:`, err);
}
}
} else if (!event.candidate) {
console.log('🧊 ICE gathering complete (null candidate)');
}
};
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);
}
cleanup(): void {
// Clean up ICE candidate handler if it exists
if (this.iceCandidateHandler) {
this.peer.pc.removeEventListener('icecandidate', this.iceCandidateHandler);
}
}
async close(): Promise<void> {
this.cleanup();
const { ClosedState } = await import('./closed-state.js');
this.peer.setState(new ClosedState(this.peer));
}
}

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

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

View File

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

View File

@@ -1,266 +1,296 @@
import { RondevuClient } from './client'; import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuConnection } from './connection'; import { RondevuOffers } from './offers.js';
import { RondevuOptions, JoinOptions, RondevuConnectionParams } from './types'; import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { DurableService } from './durable/service.js';
import { DurableConnection } from './durable/connection.js';
import { DurableChannel } from './durable/channel.js';
import type {
DurableServiceConfig,
DurableConnectionConfig,
ConnectionInfo
} from './durable/types.js';
export interface RondevuOptions {
/**
* Base URL of the Rondevu server
* @default 'https://api.ronde.vu'
*/
baseUrl?: string;
/**
* Existing credentials (peerId + secret) to skip registration
*/
credentials?: Credentials;
/**
* Custom fetch implementation for environments without native fetch
* (Node.js < 18, some Workers environments, etc.)
*
* @example Node.js
* ```typescript
* import fetch from 'node-fetch';
* const client = new Rondevu({ fetch });
* ```
*/
fetch?: FetchFunction;
/**
* Custom RTCPeerConnection implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCPeerConnection } from 'wrtc';
* const client = new Rondevu({ RTCPeerConnection });
* ```
*/
RTCPeerConnection?: typeof RTCPeerConnection;
/**
* Custom RTCSessionDescription implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCSessionDescription } from 'wrtc';
* const client = new Rondevu({ RTCSessionDescription });
* ```
*/
RTCSessionDescription?: typeof RTCSessionDescription;
/**
* Custom RTCIceCandidate implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCIceCandidate } from 'wrtc';
* const client = new Rondevu({ RTCIceCandidate });
* ```
*/
RTCIceCandidate?: typeof RTCIceCandidate;
}
/**
* Main Rondevu WebRTC client with automatic connection management
*/
export class Rondevu { export class Rondevu {
readonly peerId: string; readonly auth: RondevuAuth;
readonly usernames: RondevuUsername;
private client: RondevuClient; private _offers?: RondevuOffers;
private rtcConfig?: RTCConfiguration; private credentials?: Credentials;
private pollingInterval: number; private baseUrl: string;
private connectionTimeout: number; private fetchFn?: FetchFunction;
private rtcPeerConnection?: typeof RTCPeerConnection;
private rtcSessionDescription?: typeof RTCSessionDescription;
private rtcIceCandidate?: typeof RTCIceCandidate;
/** constructor(options: RondevuOptions = {}) {
* Creates a new Rondevu client instance this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
* @param options - Client configuration options this.fetchFn = options.fetch;
*/ this.rtcPeerConnection = options.RTCPeerConnection;
constructor(options: RondevuOptions) { this.rtcSessionDescription = options.RTCSessionDescription;
this.client = new RondevuClient({ this.rtcIceCandidate = options.RTCIceCandidate;
baseUrl: options.baseUrl,
origin: options.origin,
fetch: options.fetch,
});
// Auto-generate peer ID if not provided this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
this.peerId = options.peerId || this.generatePeerId(); this.usernames = new RondevuUsername(this.baseUrl);
this.rtcConfig = options.rtcConfig;
this.pollingInterval = options.pollingInterval || 1000;
this.connectionTimeout = options.connectionTimeout || 30000;
}
/** if (options.credentials) {
* Generate a unique peer ID this.credentials = options.credentials;
*/ this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
private generatePeerId(): string {
return `rdv_${Math.random().toString(36).substring(2, 14)}`;
}
/**
* Update the peer ID (useful when user identity changes)
*/
updatePeerId(newPeerId: string): void {
(this as any).peerId = newPeerId;
}
/**
* Create a new connection (offerer role)
* @param id - Connection identifier
* @param topic - Topic name for grouping connections
* @returns Promise that resolves to RondevuConnection
*/
async create(id: string, topic: string): Promise<RondevuConnection> {
// Create peer connection
const pc = new RTCPeerConnection(this.rtcConfig);
// Create initial data channel for negotiation (required for offer creation)
pc.createDataChannel('_negotiation');
// Generate offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering to complete
await this.waitForIceGathering(pc);
// Create session on server with custom code
await this.client.createOffer(topic, {
peerId: this.peerId,
offer: pc.localDescription!.sdp,
code: id,
});
// Create connection object
const connectionParams: RondevuConnectionParams = {
id,
topic,
role: 'offerer',
pc,
localPeerId: this.peerId,
remotePeerId: '', // Will be populated when answer is received
pollingInterval: this.pollingInterval,
connectionTimeout: this.connectionTimeout,
};
const connection = new RondevuConnection(connectionParams, this.client);
// Start polling for answer
connection.startPolling();
return connection;
}
/**
* Connect to an existing connection by ID (answerer role)
* @param id - Connection identifier
* @returns Promise that resolves to RondevuConnection
*/
async connect(id: string): Promise<RondevuConnection> {
// Poll server to get session by ID
const sessionData = await this.findSessionById(id);
if (!sessionData) {
throw new Error(`Connection ${id} not found or expired`);
} }
// Create peer connection
const pc = new RTCPeerConnection(this.rtcConfig);
// Set remote offer
await pc.setRemoteDescription({
type: 'offer',
sdp: sessionData.offer,
});
// Generate answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Wait for ICE gathering
await this.waitForIceGathering(pc);
// Send answer to server
await this.client.sendAnswer({
code: id,
answer: pc.localDescription!.sdp,
side: 'answerer',
});
// Create connection object
const connectionParams: RondevuConnectionParams = {
id,
topic: sessionData.topic || 'unknown',
role: 'answerer',
pc,
localPeerId: this.peerId,
remotePeerId: sessionData.peerId,
pollingInterval: this.pollingInterval,
connectionTimeout: this.connectionTimeout,
};
const connection = new RondevuConnection(connectionParams, this.client);
// Start polling for ICE candidates
connection.startPolling();
return connection;
} }
/** /**
* Join a topic and discover available peers (answerer role) * Get offers API (low-level access, requires authentication)
* @param topic - Topic name * For most use cases, use the durable connection APIs instead
* @param options - Optional join options for filtering and selection
* @returns Promise that resolves to RondevuConnection
*/ */
async join(topic: string, options?: JoinOptions): Promise<RondevuConnection> { get offers(): RondevuOffers {
// List sessions in topic if (!this._offers) {
const { sessions } = await this.client.listSessions(topic); throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._offers;
}
// Filter out self (sessions with our peer ID) /**
let availableSessions = sessions.filter( * Register and initialize authenticated client
session => session.peerId !== this.peerId * Generates a cryptographically random peer ID (128-bit)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create offers API instance
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
); );
// Apply custom filter if provided return this.credentials;
if (options?.filter) { }
availableSessions = availableSessions.filter(options.filter);
/**
* Check if client is authenticated
*/
isAuthenticated(): boolean {
return !!this.credentials;
}
/**
* Get current credentials
*/
getCredentials(): Credentials | undefined {
return this.credentials;
}
/**
* Create a new WebRTC peer connection (requires authentication)
* This is a high-level helper that creates and manages WebRTC connections with state management
*
* @param rtcConfig Optional RTCConfiguration for the peer connection
* @returns RondevuPeer instance
*/
createPeer(rtcConfig?: RTCConfiguration): RondevuPeer {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
} }
if (availableSessions.length === 0) { return new RondevuPeer(
throw new Error(`No available peers in topic: ${topic}`); this._offers,
rtcConfig,
undefined, // No existing peer connection
this.rtcPeerConnection,
this.rtcSessionDescription,
this.rtcIceCandidate
);
}
/**
* Expose a durable service with automatic reconnection and TTL refresh
*
* Creates a service that handles incoming connections with automatic
* reconnection and message queuing during network interruptions.
*
* @param config Service configuration
* @returns DurableService instance
*
* @example
* ```typescript
* const service = await client.exposeService({
* username: 'alice',
* privateKey: keypair.privateKey,
* serviceFqn: 'chat@1.0.0',
* poolSize: 10,
* handler: (channel, connectionId) => {
* channel.on('message', (data) => {
* console.log('Received:', data);
* channel.send(`Echo: ${data}`);
* });
* }
* });
*
* await service.start();
* ```
*/
async exposeService(
config: DurableServiceConfig & {
handler: (channel: DurableChannel, connectionId: string) => void | Promise<void>;
}
): Promise<DurableService> {
if (!this._offers || !this.credentials) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
} }
// Select session based on strategy const service = new DurableService(
const selectedSession = this.selectSession( this._offers,
availableSessions, this.baseUrl,
options?.select || 'first' this.credentials,
config.handler,
config
); );
// Connect to selected session return service;
return this.connect(selectedSession.code);
} }
/** /**
* Select a session based on strategy * Create a durable connection to a service by username and service FQN
*
* Establishes a WebRTC connection with automatic reconnection and
* message queuing during network interruptions.
*
* @param username Username of the service provider
* @param serviceFqn Fully qualified service name
* @param config Optional connection configuration
* @returns DurableConnection instance
*
* @example
* ```typescript
* const connection = await client.connect('alice', 'chat@1.0.0', {
* maxReconnectAttempts: 5
* });
*
* const channel = connection.createChannel('main');
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* await connection.connect();
* channel.send('Hello!');
* ```
*/ */
private selectSession( async connect(
sessions: Array<{ code: string; peerId: string; createdAt: number }>, username: string,
strategy: 'first' | 'newest' | 'oldest' | 'random' serviceFqn: string,
): { code: string; peerId: string; createdAt: number } { config?: DurableConnectionConfig
switch (strategy) { ): Promise<DurableConnection> {
case 'first': if (!this._offers) {
return sessions[0]; throw new Error('Not authenticated. Call register() first or provide credentials.');
case 'newest':
return sessions.reduce((newest, session) =>
session.createdAt > newest.createdAt ? session : newest
);
case 'oldest':
return sessions.reduce((oldest, session) =>
session.createdAt < oldest.createdAt ? session : oldest
);
case 'random':
return sessions[Math.floor(Math.random() * sessions.length)];
default:
return sessions[0];
} }
const connectionInfo: ConnectionInfo = {
username,
serviceFqn
};
return new DurableConnection(this._offers, connectionInfo, config);
} }
/** /**
* Wait for ICE gathering to complete * Create a durable connection to a service by UUID
*
* Establishes a WebRTC connection with automatic reconnection and
* message queuing during network interruptions.
*
* @param uuid Service UUID
* @param config Optional connection configuration
* @returns DurableConnection instance
*
* @example
* ```typescript
* const connection = await client.connectByUuid('service-uuid-here', {
* maxReconnectAttempts: 5
* });
*
* const channel = connection.createChannel('main');
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* await connection.connect();
* channel.send('Hello!');
* ```
*/ */
private async waitForIceGathering(pc: RTCPeerConnection): Promise<void> { async connectByUuid(
if (pc.iceGatheringState === 'complete') { uuid: string,
return; config?: DurableConnectionConfig
): Promise<DurableConnection> {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
} }
return new Promise((resolve) => { const connectionInfo: ConnectionInfo = {
const checkState = () => { uuid
if (pc.iceGatheringState === 'complete') { };
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState); return new DurableConnection(this._offers, connectionInfo, config);
// Also set a timeout in case gathering takes too long
setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}, 5000);
});
}
/**
* Find a session by connection ID
* This requires polling since we don't know which topic it's in
*/
private async findSessionById(id: string): Promise<{
code: string;
peerId: string;
offer: string;
topic?: string;
} | null> {
try {
// Try to poll for the session directly
// The poll endpoint should return the session data
const response = await this.client.poll(id, 'answerer');
const answererResponse = response as { offer: string; offerCandidates: string[] };
if (answererResponse.offer) {
return {
code: id,
peerId: '', // Will be populated from session data
offer: answererResponse.offer,
topic: undefined,
};
}
return null;
} catch (err) {
throw new Error(`Failed to find session ${id}: ${(err as Error).message}`);
}
} }
} }

590
src/service-pool.ts Normal file
View File

@@ -0,0 +1,590 @@
import { RondevuOffers, Offer } from './offers.js';
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { OfferPool, AnsweredOffer } from './offer-pool.js';
/**
* Connection information for tracking active connections
*/
interface ConnectionInfo {
peer: RondevuPeer;
channel: RTCDataChannel;
connectedAt: number;
offerId: string;
}
/**
* Status information about the pool
*/
export interface PoolStatus {
/** Number of active offers in the pool */
activeOffers: number;
/** Number of currently connected peers */
activeConnections: number;
/** Total number of connections handled since start */
totalConnectionsHandled: number;
/** Number of failed offer creation attempts */
failedOfferCreations: number;
}
/**
* Configuration options for a pooled service
*/
export interface ServicePoolOptions {
/** Username that owns the service */
username: string;
/** Private key for signing service operations */
privateKey: string;
/** Fully qualified service name (e.g., com.example.chat@1.0.0) */
serviceFqn: string;
/** WebRTC configuration */
rtcConfig?: RTCConfiguration;
/** Whether the service is publicly discoverable */
isPublic?: boolean;
/** Optional metadata for the service */
metadata?: Record<string, any>;
/** Time-to-live for offers in milliseconds */
ttl?: number;
/** Handler invoked for each new connection */
handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId: string) => void;
/** Number of simultaneous open offers to maintain (default: 1) */
poolSize?: number;
/** Polling interval in milliseconds (default: 2000ms) */
pollingInterval?: number;
/** Callback for pool status updates */
onPoolStatus?: (status: PoolStatus) => void;
/** Error handler for pool operations */
onError?: (error: Error, context: string) => void;
}
/**
* Service handle with pool-specific methods
*/
export interface PooledServiceHandle {
/** Service ID */
serviceId: string;
/** Service UUID */
uuid: string;
/** Offer ID */
offerId: string;
/** Unpublish the service */
unpublish: () => Promise<void>;
/** Get current pool status */
getStatus: () => PoolStatus;
/** Manually add offers to the pool */
addOffers: (count: number) => Promise<void>;
}
/**
* Manages a pooled service with multiple concurrent connections
*
* ServicePool coordinates offer creation, answer polling, and connection
* management for services that need to handle multiple simultaneous connections.
*/
export class ServicePool {
private offerPool?: OfferPool;
private connections: Map<string, ConnectionInfo> = new Map();
private peerConnections: Map<string, RTCPeerConnection> = new Map();
private status: PoolStatus = {
activeOffers: 0,
activeConnections: 0,
totalConnectionsHandled: 0,
failedOfferCreations: 0
};
private serviceId?: string;
private uuid?: string;
private offersApi: RondevuOffers;
private usernameApi: RondevuUsername;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string },
private options: ServicePoolOptions
) {
this.offersApi = new RondevuOffers(baseUrl, credentials);
this.usernameApi = new RondevuUsername(baseUrl);
}
/**
* Start the pooled service
*/
async start(): Promise<PooledServiceHandle> {
const poolSize = this.options.poolSize || 1;
// 1. Create initial service (publishes first offer)
const service = await this.publishInitialService();
this.serviceId = service.serviceId;
this.uuid = service.uuid;
// 2. Create additional offers for pool (poolSize - 1)
const additionalOffers: Offer[] = [];
const additionalPeerConnections: RTCPeerConnection[] = [];
const additionalDataChannels: RTCDataChannel[] = [];
if (poolSize > 1) {
try {
const result = await this.createOffers(poolSize - 1);
additionalOffers.push(...result.offers);
additionalPeerConnections.push(...result.peerConnections);
additionalDataChannels.push(...result.dataChannels);
} catch (error) {
this.handleError(error as Error, 'initial-offer-creation');
}
}
// 3. Initialize OfferPool with all offers
this.offerPool = new OfferPool(this.offersApi, {
poolSize,
pollingInterval: this.options.pollingInterval || 2000,
onAnswered: (answer) => this.handleConnection(answer),
onRefill: (count) => this.createOffers(count),
onError: (err, ctx) => this.handleError(err, ctx)
});
// Add all offers to pool with their peer connections and data channels
const allOffers = [
{ id: service.offerId, peerId: this.credentials.peerId, sdp: service.offerSdp, topics: [], expiresAt: service.expiresAt, lastSeen: Date.now() },
...additionalOffers
];
const allPeerConnections = [
service.peerConnection,
...additionalPeerConnections
];
const allDataChannels = [
service.dataChannel,
...additionalDataChannels
];
await this.offerPool.addOffers(allOffers, allPeerConnections, allDataChannels);
// 4. Start polling
await this.offerPool.start();
// Update status
this.updateStatus();
// 5. Return handle
return {
serviceId: this.serviceId,
uuid: this.uuid,
offerId: service.offerId,
unpublish: () => this.stop(),
getStatus: () => this.getStatus(),
addOffers: (count) => this.manualRefill(count)
};
}
/**
* Stop the pooled service and clean up
*/
async stop(): Promise<void> {
// 1. Stop accepting new connections
if (this.offerPool) {
await this.offerPool.stop();
}
// 2. Close peer connections from the pool
if (this.offerPool) {
const poolPeerConnections = this.offerPool.getActivePeerConnections();
poolPeerConnections.forEach(pc => {
try {
pc.close();
} catch {
// Ignore errors during cleanup
}
});
}
// 3. Delete remaining offers
if (this.offerPool) {
const offerIds = this.offerPool.getActiveOfferIds();
await Promise.allSettled(
offerIds.map(id => this.offersApi.delete(id).catch(() => {}))
);
}
// 4. Close active connections
const closePromises = Array.from(this.connections.values()).map(
async (conn) => {
try {
// Give a brief moment for graceful closure
await new Promise(resolve => setTimeout(resolve, 100));
conn.peer.pc.close();
} catch {
// Ignore errors during cleanup
}
}
);
await Promise.allSettled(closePromises);
// 5. Delete service if we have a serviceId
if (this.serviceId) {
try {
const response = await fetch(`${this.baseUrl}/services/${this.serviceId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({ username: this.options.username })
});
if (!response.ok) {
console.error('Failed to delete service:', await response.text());
}
} catch (error) {
console.error('Error deleting service:', error);
}
}
// Clear all state
this.connections.clear();
this.offerPool = undefined;
}
/**
* Handle an answered offer by setting up the connection
*/
private async handleConnection(answer: AnsweredOffer): Promise<void> {
const connectionId = this.generateConnectionId();
try {
// Use the existing peer connection from the pool
const peer = new RondevuPeer(
this.offersApi,
this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
},
answer.peerConnection // Use the existing peer connection
);
peer.role = 'offerer';
peer.offerId = answer.offerId;
// Verify peer connection is in correct state
if (peer.pc.signalingState !== 'have-local-offer') {
console.error('Peer connection state info:', {
signalingState: peer.pc.signalingState,
connectionState: peer.pc.connectionState,
iceConnectionState: peer.pc.iceConnectionState,
iceGatheringState: peer.pc.iceGatheringState,
hasLocalDescription: !!peer.pc.localDescription,
hasRemoteDescription: !!peer.pc.remoteDescription,
localDescriptionType: peer.pc.localDescription?.type,
remoteDescriptionType: peer.pc.remoteDescription?.type,
offerId: answer.offerId
});
throw new Error(
`Invalid signaling state: ${peer.pc.signalingState}. Expected 'have-local-offer' to set remote answer.`
);
}
// Set remote description (the answer)
await peer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
// Use the data channel we created when making the offer
if (!answer.dataChannel) {
throw new Error('No data channel found for answered offer');
}
const channel = answer.dataChannel;
// Wait for the channel to open (it was created when we made the offer)
if (channel.readyState !== 'open') {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timeout waiting for data channel to open')),
30000
);
channel.onopen = () => {
clearTimeout(timeout);
resolve();
};
channel.onerror = (error) => {
clearTimeout(timeout);
reject(new Error('Data channel error'));
};
});
}
// Register connection
this.connections.set(connectionId, {
peer,
channel,
connectedAt: Date.now(),
offerId: answer.offerId
});
this.status.activeConnections++;
this.status.totalConnectionsHandled++;
// Setup cleanup on disconnect
peer.on('disconnected', () => {
this.connections.delete(connectionId);
this.status.activeConnections--;
this.updateStatus();
});
peer.on('failed', () => {
this.connections.delete(connectionId);
this.status.activeConnections--;
this.updateStatus();
});
// Update status
this.updateStatus();
// Invoke user handler (wrapped in try-catch)
try {
this.options.handler(channel, peer, connectionId);
} catch (handlerError) {
this.handleError(handlerError as Error, 'handler');
}
} catch (error) {
this.handleError(error as Error, 'connection-setup');
}
}
/**
* Create multiple offers
*/
private async createOffers(count: number): Promise<{ offers: Offer[], peerConnections: RTCPeerConnection[], dataChannels: RTCDataChannel[] }> {
if (count <= 0) {
return { offers: [], peerConnections: [], dataChannels: [] };
}
// Server supports max 10 offers per request
const batchSize = Math.min(count, 10);
const offers: Offer[] = [];
const peerConnections: RTCPeerConnection[] = [];
const dataChannels: RTCDataChannel[] = [];
try {
// Create peer connections and generate offers
const offerRequests = [];
for (let i = 0; i < batchSize; i++) {
const pc = new RTCPeerConnection(this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Create data channel (required for offers) and save reference
const channel = pc.createDataChannel('rondevu-service');
dataChannels.push(channel);
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate SDP');
}
offerRequests.push({
sdp: offer.sdp,
topics: [], // V2 doesn't use topics
ttl: this.options.ttl
});
// Keep peer connection alive - DO NOT CLOSE
peerConnections.push(pc);
}
// Batch create offers
const createdOffers = await this.offersApi.create(offerRequests);
offers.push(...createdOffers);
// Set up ICE candidate handlers AFTER we have offer IDs
for (let i = 0; i < peerConnections.length; i++) {
const pc = peerConnections[i];
const offerId = createdOffers[i].id;
pc.onicecandidate = async (event) => {
if (event.candidate) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.offersApi.addIceCandidates(offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
}
} catch (error) {
// Close any created peer connections on error
peerConnections.forEach(pc => pc.close());
this.status.failedOfferCreations++;
this.handleError(error as Error, 'offer-creation');
throw error;
}
return { offers, peerConnections, dataChannels };
}
/**
* Publish the initial service (creates first offer)
*/
private async publishInitialService(): Promise<{
serviceId: string;
uuid: string;
offerId: string;
offerSdp: string;
expiresAt: number;
peerConnection: RTCPeerConnection;
dataChannel: RTCDataChannel;
}> {
const { username, privateKey, serviceFqn, rtcConfig, isPublic, metadata, ttl } = this.options;
// Create peer connection for initial offer
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
const dataChannel = pc.createDataChannel('rondevu-service');
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate SDP');
}
// Store the SDP
const offerSdp = offer.sdp;
// Create signature
const timestamp = Date.now();
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
const signature = await this.usernameApi.signMessage(message, privateKey);
// Publish service
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({
username,
serviceFqn,
sdp: offerSdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
if (!response.ok) {
pc.close();
const error = await response.json();
throw new Error(error.error || 'Failed to publish service');
}
const data = await response.json();
// Set up ICE candidate handler now that we have the offer ID
pc.onicecandidate = async (event) => {
if (event.candidate) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.offersApi.addIceCandidates(data.offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
offerSdp,
expiresAt: data.expiresAt,
peerConnection: pc, // Keep peer connection alive
dataChannel // Keep data channel alive
};
}
/**
* Manually add offers to the pool
*/
private async manualRefill(count: number): Promise<void> {
if (!this.offerPool) {
throw new Error('Pool not started');
}
const result = await this.createOffers(count);
await this.offerPool.addOffers(result.offers, result.peerConnections, result.dataChannels);
this.updateStatus();
}
/**
* Get current pool status
*/
private getStatus(): PoolStatus {
return { ...this.status };
}
/**
* Update status and notify listeners
*/
private updateStatus(): void {
if (this.offerPool) {
this.status.activeOffers = this.offerPool.getActiveOfferCount();
}
if (this.options.onPoolStatus) {
this.options.onPoolStatus(this.getStatus());
}
}
/**
* Handle errors
*/
private handleError(error: Error, context: string): void {
if (this.options.onError) {
this.options.onError(error, context);
} else {
console.error(`ServicePool error (${context}):`, error);
}
}
/**
* Generate a unique connection ID
*/
private generateConnectionId(): string {
return `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}

View File

@@ -1,232 +0,0 @@
// ============================================================================
// Signaling Types
// ============================================================================
/**
* Session side - identifies which peer in a connection
*/
export type Side = 'offerer' | 'answerer';
/**
* Session information returned from discovery endpoints
*/
export interface Session {
/** Unique session identifier (UUID) */
code: string;
/** Peer identifier/metadata */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
/** Unix timestamp when session was created */
createdAt: number;
/** Unix timestamp when session expires */
expiresAt: number;
}
/**
* Topic information with peer count
*/
export interface TopicInfo {
/** Topic identifier */
topic: string;
/** Number of available peers in this topic */
count: number;
}
/**
* Pagination information
*/
export interface Pagination {
/** Current page number */
page: number;
/** Results per page */
limit: number;
/** Total number of results */
total: number;
/** Whether there are more results available */
hasMore: boolean;
}
/**
* Response from GET / - list all topics
*/
export interface ListTopicsResponse {
topics: TopicInfo[];
pagination: Pagination;
}
/**
* Response from GET /:topic/sessions - list sessions in a topic
*/
export interface ListSessionsResponse {
sessions: Session[];
}
/**
* Request body for POST /:topic/offer
*/
export interface CreateOfferRequest {
/** Peer identifier/metadata (max 1024 characters) */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Optional custom connection code (if not provided, server generates UUID) */
code?: string;
}
/**
* Response from POST /:topic/offer
*/
export interface CreateOfferResponse {
/** Unique session identifier (UUID) */
code: string;
}
/**
* Request body for POST /answer
*/
export interface AnswerRequest {
/** Session UUID from the offer */
code: string;
/** Response signaling data (required if candidate not provided) */
answer?: string;
/** Additional signaling data (required if answer not provided) */
candidate?: string;
/** Which peer is sending the data */
side: Side;
}
/**
* Response from POST /answer
*/
export interface AnswerResponse {
success: boolean;
}
/**
* Request body for POST /poll
*/
export interface PollRequest {
/** Session UUID */
code: string;
/** Which side is polling */
side: Side;
}
/**
* Response from POST /poll when side=offerer
*/
export interface PollOffererResponse {
/** Answer from answerer (null if not yet received) */
answer: string | null;
/** Additional signaling data from answerer */
answerCandidates: string[];
}
/**
* Response from POST /poll when side=answerer
*/
export interface PollAnswererResponse {
/** Offer from offerer */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
}
/**
* Response from POST /poll (union type)
*/
export type PollResponse = PollOffererResponse | PollAnswererResponse;
/**
* Response from GET /health
*/
export interface HealthResponse {
status: 'ok';
timestamp: number;
}
/**
* Error response structure
*/
export interface ErrorResponse {
error: string;
}
/**
* Client configuration options
*/
export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
// ============================================================================
// WebRTC Types
// ============================================================================
/**
* Configuration options for Rondevu WebRTC client
*/
export interface RondevuOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Peer identifier (optional, auto-generated if not provided) */
peerId?: string;
/** Origin header value for session isolation (defaults to baseUrl origin) */
origin?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */
rtcConfig?: RTCConfiguration;
/** Polling interval in milliseconds (default: 1000) */
pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number;
}
/**
* Options for joining a topic
*/
export interface JoinOptions {
/** Filter function to select specific sessions */
filter?: (session: { code: string; peerId: string }) => boolean;
/** Selection strategy for choosing a session */
select?: 'first' | 'newest' | 'oldest' | 'random';
}
/**
* Connection role - whether this peer is creating or answering
*/
export type ConnectionRole = 'offerer' | 'answerer';
/**
* Parameters for creating a RondevuConnection
*/
export interface RondevuConnectionParams {
id: string;
topic: string;
role: ConnectionRole;
pc: RTCPeerConnection;
localPeerId: string;
remotePeerId: string;
pollingInterval: number;
connectionTimeout: number;
}
/**
* Event map for RondevuConnection events
*/
export interface RondevuConnectionEvents {
connect: () => void;
disconnect: () => void;
error: (error: Error) => void;
datachannel: (channel: RTCDataChannel) => void;
stream: (stream: MediaStream) => void;
}

200
src/usernames.ts Normal file
View File

@@ -0,0 +1,200 @@
import * as ed25519 from '@noble/ed25519';
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
// Uses built-in WebCrypto API which only provides async digest
// We use the async ed25519 functions (signAsync, verifyAsync, getPublicKeyAsync)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
};
/**
* Username claim result
*/
export interface UsernameClaimResult {
username: string;
publicKey: string;
privateKey: string;
claimedAt: number;
expiresAt: number;
}
/**
* Username availability check result
*/
export interface UsernameCheckResult {
username: string;
available: boolean;
claimedAt?: number;
expiresAt?: number;
publicKey?: string;
}
/**
* Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join('');
return btoa(binString);
}
/**
* Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64);
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
}
/**
* Rondevu Username API
* Handles username claiming with Ed25519 cryptographic proof
*/
export class RondevuUsername {
constructor(private baseUrl: string) {}
/**
* Generates an Ed25519 keypair for username claiming
*/
async generateKeypair(): Promise<{ publicKey: string; privateKey: string }> {
const privateKey = ed25519.utils.randomSecretKey();
const publicKey = await ed25519.getPublicKeyAsync(privateKey);
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey)
};
}
/**
* Signs a message with an Ed25519 private key
*/
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);
}
/**
* Claims a username
* Generates a new keypair if one is not provided
*/
async claimUsername(
username: string,
existingKeypair?: { publicKey: string; privateKey: string }
): Promise<UsernameClaimResult> {
// Generate or use existing keypair
const keypair = existingKeypair || await this.generateKeypair();
// Create signed message
const timestamp = Date.now();
const message = `claim:${username}:${timestamp}`;
const signature = await this.signMessage(message, keypair.privateKey);
// Send claim request
const response = await fetch(`${this.baseUrl}/usernames/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
publicKey: keypair.publicKey,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to claim username');
}
const data = await response.json();
return {
username: data.username,
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
claimedAt: data.claimedAt,
expiresAt: data.expiresAt
};
}
/**
* Checks if a username is available
*/
async checkUsername(username: string): Promise<UsernameCheckResult> {
const response = await fetch(`${this.baseUrl}/usernames/${username}`);
if (!response.ok) {
throw new Error('Failed to check username');
}
const data = await response.json();
return {
username: data.username,
available: data.available,
claimedAt: data.claimedAt,
expiresAt: data.expiresAt,
publicKey: data.publicKey
};
}
/**
* Helper: Save keypair to localStorage
* WARNING: This stores the private key in localStorage which is not the most secure
* For production use, consider using IndexedDB with encryption or hardware security modules
*/
saveKeypairToStorage(username: string, publicKey: string, privateKey: string): void {
const data = { username, publicKey, privateKey, savedAt: Date.now() };
localStorage.setItem(`rondevu:keypair:${username}`, JSON.stringify(data));
}
/**
* Helper: Load keypair from localStorage
*/
loadKeypairFromStorage(username: string): { publicKey: string; privateKey: string } | null {
const stored = localStorage.getItem(`rondevu:keypair:${username}`);
if (!stored) return null;
try {
const data = JSON.parse(stored);
return { publicKey: data.publicKey, privateKey: data.privateKey };
} catch {
return null;
}
}
/**
* Helper: Delete keypair from localStorage
*/
deleteKeypairFromStorage(username: string): void {
localStorage.removeItem(`rondevu:keypair:${username}`);
}
/**
* Export keypair as JSON string (for backup)
*/
exportKeypair(publicKey: string, privateKey: string): string {
return JSON.stringify({
publicKey,
privateKey,
exportedAt: Date.now()
});
}
/**
* Import keypair from JSON string
*/
importKeypair(json: string): { publicKey: string; privateKey: string } {
const data = JSON.parse(json);
if (!data.publicKey || !data.privateKey) {
throw new Error('Invalid keypair format');
}
return { publicKey: data.publicKey, privateKey: data.privateKey };
}
}