43 Commits

Author SHA1 Message Date
0aa9921941 Release v0.17.0: Phase 1 & 2 improvements, documentation restructuring 2025-12-13 13:18:43 +01:00
5fc20f1be9 Remove Node.js Host Guide references
Remove all references to NODE_HOST_GUIDE.md and test-connect.js:
- README.md: Removed Node.js Host Guide section and links
- ADVANCED.md: Simplified Node.js example, removed guide references

Keep inline Node.js example code for reference.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:24:56 +01:00
54c371f451 Fix broken relative links to demo repository
Change relative paths to absolute GitHub URLs:
- ../demo/NODE_HOST_GUIDE.md → github.com/xtr-dev/rondevu-demo/blob/main/NODE_HOST_GUIDE.md
- ../demo/test-connect.js → github.com/xtr-dev/rondevu-demo/blob/main/test-connect.js

Affected files:
- README.md (2 links fixed)
- ADVANCED.md (2 links fixed)

Why: Client and demo are separate repositories, so relative paths
don't work when viewing on GitHub. Now links work from anywhere.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:18:50 +01:00
5f4743e086 Make README more concise with ADVANCED.md
Restructure documentation for better discoverability:

Changes:
- README.md: 551 → 181 lines (67% reduction)
- ADVANCED.md: New comprehensive guide (500+ lines)

README.md now contains:
 Features overview
 Installation
 Quick start examples (offerer & answerer)
 Core API summary
 Links to advanced docs

ADVANCED.md contains:
📚 Complete API reference (all methods)
📚 Type definitions
📚 Platform support details (Browser & Node.js)
📚 Advanced usage patterns
📚 Username rules and FQN format
📚 Migration guides
📚 Examples

Benefits:
- Faster onboarding for new users
- Essential info in README
- Detailed reference still accessible
- Better documentation structure

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:14:54 +01:00
2ce3e98df0 Improve type safety: Replace any with proper types
Replace `candidate: any` with `candidate: RTCIceCandidateInit | null`:

Changes:
- api.ts poll() return type (line 366)
- rondevu.ts pollOffers() return type (line 827)
- IceCandidate interface (line 41)

Benefits:
- Better type safety and IntelliSense support
- Matches WebRTC spec (candidates can be null)
- Catches potential type errors at compile time
- Clearer API contracts for consumers

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:10:57 +01:00
800f6eaa94 Refactor connectToService() method for better maintainability
Break down 129-line method into focused helper methods:

New private methods:
- resolveServiceFqn(): Determines full FQN from various input options
  Handles direct FQN, service+username, or discovery mode

- startIcePolling(): Manages remote ICE candidate polling
  Encapsulates polling logic and interval management

Benefits:
- connectToService() reduced from 129 to ~98 lines
- Each method has single responsibility
- Easier to test and maintain
- Better code readability with clear method names
- Reusable components for future features

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:07:49 +01:00
d6a9876440 Extract duplicate ICE candidate handling code
Refactor ICE candidate handler setup:
- Create setupIceCandidateHandler() private method
- Remove duplicate code from createOffer() and connectToService()
- Both methods now call the shared handler setup

Benefits:
- DRY principle: 13 lines of duplicate code eliminated
- Easier to maintain: changes to ICE handling logic only needed in one place
- Consistent error handling across both code paths

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:06:22 +01:00
9262043e97 Add debug mode and wrap all console.log statements
Implements opt-in debug logging system:
- Add debug?: boolean option to RondevuOptions
- Add private debug() method that only logs when debug mode is enabled
- Replace all 25+ console.log statements with this.debug() calls
- Static connect() method checks options.debug before logging

Benefits:
- Clean production console output by default
- Users can enable debug logging when needed: debug: true
- All debug messages prefixed with [Rondevu]
- Error logging (console.error) preserved for important failures

Usage:
```typescript
const rondevu = await Rondevu.connect({
  apiUrl: 'https://api.ronde.vu',
  username: 'alice',
  debug: true  // Enable debug logging
})
```

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 23:05:13 +01:00
bd16798a2f Replace magic numbers with named constants in client
Refactoring: Extract magic numbers to static constants
- DEFAULT_TTL_MS = 300000 (5 minutes)
- POLLING_INTERVAL_MS = 1000 (1 second)

Replaced in:
- ttl property initialization (line 173)
- publishService() default (line 335)
- startFilling() polling interval (line 500)
- connectToService() ICE polling (line 636)

Impact: Improves code clarity and maintainability
2025-12-12 22:55:24 +01:00
c662161cd9 Add input validation to connectToService()
Validation: Add checks for empty strings in connectToService()
- Validates serviceFqn is not empty string
- Validates service is not empty string
- Validates username is not empty string
- Prevents silent failures from whitespace-only inputs

Impact: Better error messages for invalid inputs
2025-12-12 22:51:58 +01:00
a9a0127ccf Remove unused imports: Service and ServiceRequest
Cleanup: Remove Service and ServiceRequest types from imports
- Not used anywhere in rondevu.ts
- Reduces unnecessary dependencies
- Verified: Build passes with no TypeScript errors
2025-12-12 22:51:20 +01:00
4345709e9c Remove RondevuSignaler documentation and outdated files
- Remove entire RondevuSignaler class documentation section
- Remove PollingConfig interface from Types section
- Delete obsolete USAGE.md file (completely outdated)
- Update all examples to use new automatic API
- Fix migration examples to show current publishService() format
2025-12-12 22:43:48 +01:00
43dfd72c3d Remove all legacy and backward compatibility support
- Remove serviceFqn, offers array from PublishServiceOptions
- Make service and maxOffers required fields
- Simplify publishService() to only support automatic mode
- Remove RondevuSignaler class completely
- Update exports to include new types (ConnectionContext, ConnectToServiceOptions)
- Update test-connect.js to use connectToService()
- Remove all "manual mode" and "legacy" references from documentation

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 22:33:29 +01:00
ec19ce50db Add connectToService() for automatic answering side setup
- Add ConnectToServiceOptions and ConnectionContext interfaces
- Implement connectToService() method that handles entire answering flow
- Automatically discovers/gets service, creates RTCPeerConnection, exchanges answer and ICE candidates
- Supports both direct lookup (serviceFqn) and discovery (service)
- Returns connection context with pc, dc, serviceFqn, offerId, and peerUsername
- Update README with automatic mode examples

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 22:26:44 +01:00
e5c82b75b1 Add ICE server preset support and update to Rondevu.connect()
- Add 4 ICE server presets: ipv4-turn, hostname-turns, google-stun, relay-only
- Update Rondevu.connect() to accept preset string or custom RTCIceServer array
- Support both automatic (maxOffers) and manual (offers array) modes in publishService()
- Rename internal poll() to pollInternal() to avoid conflict with public API
- Update README examples to use presets and proper offer factory pattern

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 22:13:03 +01:00
a2c01d530f Simplify API: Remove explicit claiming, add Rondevu.connect(), simplify publishService
Breaking changes:
- Remove claimUsername() method - username claiming now fully implicit
- Remove initialize() method - replaced with static Rondevu.connect()
- Change publishService() parameter from serviceFqn to service (username auto-appended)
- Make constructor private - must use Rondevu.connect()
- Remove nullable types from keypair/api - always initialized after connect()

New pattern:
const rondevu = await Rondevu.connect({ apiUrl, username })
await rondevu.publishService({ service: 'chat:2.0.0', offers })

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 21:56:37 +01:00
7223e45b98 Implement RPC request batching and throttling
Adds automatic request batching to reduce HTTP overhead by combining
multiple RPC calls into a single request.

Features:
- RpcBatcher class for intelligent request batching
- Configurable batch size (default: 10 requests)
- Configurable wait time (default: 50ms)
- Throttling to prevent overwhelming the server (default: 10ms)
- Automatic flushing when batch is full
- Enabled by default, can be disabled via options

Changes:
- Created rpc-batcher.ts with RpcBatcher class
- Updated RondevuAPI to use batcher by default
- Added batching option to RondevuOptions
- Updated README with batching documentation
- Bumped version to 0.16.0

Example usage:
  // Default (batching enabled with defaults)
  const rondevu = new Rondevu({ apiUrl: 'https://api.ronde.vu' })

  // Custom batching settings
  const rondevu = new Rondevu({
    apiUrl: 'https://api.ronde.vu',
    batching: { maxBatchSize: 20, maxWaitTime: 100 }
  })

  // Disable batching
  const rondevu = new Rondevu({
    apiUrl: 'https://api.ronde.vu',
    batching: false
  })

This can reduce HTTP requests by up to 90% during intensive operations
like ICE candidate exchange.

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 20:42:02 +01:00
d55abf2b63 Implement crypto adapter pattern for platform independence
Adds CryptoAdapter interface with WebCryptoAdapter (browser) and
NodeCryptoAdapter (Node.js 19+) implementations.

Changes:
- Created crypto-adapter.ts interface
- Created web-crypto-adapter.ts for browser environments
- Created node-crypto-adapter.ts for Node.js environments
- Updated RondevuAPI to accept optional CryptoAdapter
- Updated Rondevu class to pass crypto adapter through
- Exported adapters and types in index.ts
- Updated README with platform support documentation
- Bumped version to 0.15.0

This allows the client library to work in both browser and Node.js
environments by providing platform-specific crypto implementations.

Example usage in Node.js:
  import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'

  const rondevu = new Rondevu({
    apiUrl: 'https://api.ronde.vu',
    cryptoAdapter: new NodeCryptoAdapter()
  })

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 20:34:27 +01:00
4ce5217135 Fix: Remove redundant signature generation in publishService
The rondevu.ts wrapper was generating its own message and signature
which were being ignored by the API layer (which generates its own).
This caused the old signature to be passed but not used.

Simplified by removing the redundant code and letting the API layer
handle all authentication.

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 20:26:48 +01:00
238cc08bf5 Send publicKey in RPC requests for implicit username claiming
Updated client to send publicKey in all authenticated RPC requests,
enabling server-side implicit username claiming.

Changes:
- Added publicKey field to RpcRequest interface
- Added publicKey to all authenticated RPC method calls
- Removed username claiming requirement from publishService()
- Auto-mark username as claimed after successful publish

Users no longer need to call claimUsername() before publishing
services. The server will automatically claim the username on
the first authenticated request.

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 20:22:28 +01:00
f4ae5dee30 chore: Bump version to 0.14.0 for RPC refactor
BREAKING CHANGE: RPC interface replaces REST API
2025-12-12 20:14:12 +01:00
a499062e52 refactor: Update client to use RPC interface
BREAKING CHANGES:
- All API calls now go to POST /rpc endpoint
- Request format: { method, message, signature, params }
- Response format: { success, result } or { success: false, error }
- Simplified API methods to match RPC methods
- Removed checkUsername, added isUsernameAvailable
- Renamed postOfferAnswer to answerOffer
- Removed discoverService/discoverServices (use getService)

Changes:
- Completely refactored api.ts for RPC interface
- Updated rondevu.ts wrapper methods
- Updated rondevu-signaler.ts to use new API
- Fixed exports in index.ts
2025-12-12 20:10:03 +01:00
b5f36d8f77 refactor: Rename pollOffers to poll and remove getAnsweredOffers
BREAKING CHANGES:
- Renamed pollOffers() to poll() (matches new /poll endpoint)
- Removed getAnsweredOffers() method (use poll() instead)
- Updated endpoint path from /offers/poll to /poll
- Updated auth message format from 'pollOffers' to 'poll'
2025-12-12 19:13:19 +01:00
214f611dc2 fix: Send signature and message in publishService body
The auth middleware expects username, signature, and message in the request
body for POST requests. The service object already contains signature and
message from rondevu.ts, so we just need to ensure they're sent by spreading
the service object and adding username.
2025-12-12 12:38:33 +01:00
1112eeefd4 feat: Remove automatic username claiming
- Removed auto-claim logic from initialize() method
- Users must now explicitly call claimUsername()
- Updated JSDoc to reflect manual claiming requirement
- Anonymous usernames are generated but not auto-claimed

BREAKING CHANGE: Anonymous users are no longer automatically claimed.
Applications must explicitly call claimUsername() before publishing services.
2025-12-12 12:01:56 +01:00
0fe8e82858 docs: Update README for unified Ed25519 authentication
- Add anonymous username documentation and examples
- Remove Credentials interface from types section
- Update Rondevu constructor to show optional username (auto-generates anonymous)
- Remove register() method references
- Update RondevuAPI constructor (no credentials, requires username + keypair)
- Add automatic signature generation notes
- Update all code examples to show new initialization flow
- Add persistent keypair example with username storage
2025-12-10 22:19:18 +01:00
c9a5e0eae6 Unified Ed25519 authentication - remove credentials system
BREAKING CHANGE: Remove credential-based authentication

- Remove Credentials interface and all credential-related code
- Remove register() method from RondevuAPI
- Remove setCredentials() and getAuthHeader() methods

RondevuAPI changes:
- Constructor now requires username and keypair (not credentials)
- Add generateAuthParams() helper for automatic signature generation
- All API methods now include {username, signature, message} auth
- POST requests: auth in body
- GET requests: auth in query params
- Remove Authorization header from all fetch calls

Rondevu class changes:
- Make username optional in RondevuOptions (auto-generates anon username)
- Make keypair optional (auto-generates if not provided)
- Add generateAnonymousUsername() method (anon-{timestamp}-{random})
- Update initialize() to create API with username+keypair (no register call)
- Auto-claim username for anonymous users during initialize()
- Add lazy getAPI() to ensure initialization

Message format for auth:
- Format: action:username:params:timestamp
- Examples: publishService:alice:chat:1.0.0@alice:1234567890
- Each request generates unique signature with timestamp

Index exports:
- Remove Credentials export (no longer exists)
2025-12-10 22:07:07 +01:00
239563ac5c Update README to document current v0.4 API
- Remove outdated ServiceHost/ServiceClient/RTCDurableConnection documentation
- Document actual Rondevu, RondevuSignaler, and RondevuAPI classes
- Add pollOffers() combined polling documentation
- Add complete usage examples for offerer and answerer sides
- Document role-based ICE candidate filtering
- Add migration guide from v0.3.x
2025-12-10 21:04:16 +01:00
3327c5b219 Replace separate polling with combined pollOffers() in RondevuSignaler
Breaking Change: Offerer now uses pollOffers() for efficient batch polling

Changes:
- Offerer: use pollOffers() for combined answer+ICE polling (1 request vs 2)
- Answerer: keep using getOfferIceCandidates() (separate endpoint still needed)
- Add isOfferer flag to track role
- Replace startAnswerPolling() with startPolling() using pollOffers()
- Filter ICE candidates by role (answerer candidates for offerer)
- Use single lastPollTimestamp for both answers and ICE
- Reduce HTTP requests by 50% for offerers
- More efficient signaling with timestamp-based filtering

No backwards compatibility maintained per user request.
2025-12-10 20:55:02 +01:00
b4be5e9060 Add role and peerId to pollOffers ICE candidate response types
- ICE candidates now include role ('offerer' | 'answerer')
- ICE candidates now include peerId for matching
- Enables clients to filter candidates by role
- Supports bidirectional ICE exchange
2025-12-10 19:51:43 +01:00
b60799a712 Add combined polling API method for answers and ICE candidates
- Add pollOffers() method to RondevuAPI class
- Expose pollOffers() in Rondevu class
- Returns both answered offers and ICE candidates in single call
- Supports timestamp-based filtering with optional 'since' parameter
- More efficient than separate getAnsweredOffers() and getOfferIceCandidates() calls
2025-12-10 19:33:11 +01:00
8fbb76a336 Add getAnsweredOffers API method for batch polling
Added RondevuAPI.getAnsweredOffers() and Rondevu.getAnsweredOffers()
methods to efficiently retrieve all answered offers with optional
timestamp filtering.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All types and classes exported from main index.

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

const bus = new EventBus<MyEvents>();

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 13:30:55 +01:00
36 changed files with 5338 additions and 4674 deletions

9
.prettierrc.json Normal file
View File

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

486
ADVANCED.md Normal file
View File

@@ -0,0 +1,486 @@
# Rondevu Client - Advanced Usage
Comprehensive guide for advanced features, platform support, and detailed API reference.
## Table of Contents
- [API Reference](#api-reference)
- [Types](#types)
- [Advanced Usage](#advanced-usage)
- [Platform Support](#platform-support)
- [Username Rules](#username-rules)
- [Service FQN Format](#service-fqn-format)
- [Examples](#examples)
- [Migration Guide](#migration-guide)
---
## API Reference
### Rondevu Class
Main class for all Rondevu operations.
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'
// Create and connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: string, // Signaling server URL
username?: string, // Optional: your username (auto-generates anonymous if omitted)
keypair?: Keypair, // Optional: reuse existing keypair
cryptoAdapter?: CryptoAdapter // Optional: platform-specific crypto (defaults to WebCryptoAdapter)
batching?: BatcherOptions | false // Optional: RPC batching configuration
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
debug?: boolean // Optional: enable debug logging (default: false)
})
```
#### Platform Support (Browser & Node.js)
The client supports both browser and Node.js environments using crypto adapters:
**Browser (default):**
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'
// WebCryptoAdapter is used by default - no configuration needed
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
```
**Node.js (19+ or 18 with --experimental-global-webcrypto):**
```typescript
import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice',
cryptoAdapter: new NodeCryptoAdapter()
})
```
**Note:** Node.js support requires:
- Node.js 19+ (crypto.subtle available globally), OR
- Node.js 18 with `--experimental-global-webcrypto` flag
- WebRTC implementation like `wrtc` or `node-webrtc` for RTCPeerConnection
**Custom Crypto Adapter:**
```typescript
import { CryptoAdapter, Keypair } from '@xtr-dev/rondevu-client'
class CustomCryptoAdapter implements CryptoAdapter {
async generateKeypair(): Promise<Keypair> { /* ... */ }
async signMessage(message: string, privateKey: string): Promise<string> { /* ... */ }
async verifySignature(message: string, signature: string, publicKey: string): Promise<boolean> { /* ... */ }
bytesToBase64(bytes: Uint8Array): string { /* ... */ }
base64ToBytes(base64: string): Uint8Array { /* ... */ }
randomBytes(length: number): Uint8Array { /* ... */ }
}
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
cryptoAdapter: new CustomCryptoAdapter()
})
```
#### Username Management
Usernames are **automatically claimed** on the first authenticated request (like `publishService()`).
```typescript
// Check if username is claimed (checks server)
await rondevu.isUsernameClaimed(): Promise<boolean>
// Get username
rondevu.getUsername(): string
// Get public key
rondevu.getPublicKey(): string
// Get keypair (for backup/storage)
rondevu.getKeypair(): Keypair
```
#### Service Publishing
```typescript
// Publish service with offers
await rondevu.publishService({
service: string, // e.g., 'chat:1.0.0' (username auto-appended)
maxOffers: number, // Maximum number of concurrent offers to maintain
offerFactory?: OfferFactory, // Optional: custom offer creation (defaults to simple data channel)
ttl?: number // Optional: milliseconds (default: 300000)
}): Promise<void>
```
#### Service Discovery
```typescript
// Direct lookup by FQN (with username)
await rondevu.getService('chat:1.0.0@alice'): Promise<ServiceOffer>
// Random discovery (without username)
await rondevu.discoverService('chat:1.0.0'): Promise<ServiceOffer>
// Paginated discovery (returns multiple offers)
await rondevu.discoverServices(
'chat:1.0.0', // serviceVersion
10, // limit
0 // offset
): Promise<{ services: ServiceOffer[], count: number, limit: number, offset: number }>
```
#### WebRTC Signaling
```typescript
// Post answer SDP
await rondevu.postOfferAnswer(
serviceFqn: string,
offerId: string,
sdp: string
): Promise<{ success: boolean, offerId: string }>
// Get answer SDP (offerer polls this - deprecated, use pollOffers instead)
await rondevu.getOfferAnswer(
serviceFqn: string,
offerId: string
): Promise<{ sdp: string, offerId: string, answererId: string, answeredAt: number } | null>
// Combined polling for answers and ICE candidates (RECOMMENDED for offerers)
await rondevu.pollOffers(since?: number): Promise<{
answers: Array<{
offerId: string
serviceId?: string
answererId: string
sdp: string
answeredAt: number
}>
iceCandidates: Record<string, Array<{
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
peerId: string
createdAt: number
}>>
}>
// Add ICE candidates
await rondevu.addOfferIceCandidates(
serviceFqn: string,
offerId: string,
candidates: RTCIceCandidateInit[]
): Promise<{ count: number, offerId: string }>
// Get ICE candidates (with polling support)
await rondevu.getOfferIceCandidates(
serviceFqn: string,
offerId: string,
since: number = 0
): Promise<{ candidates: IceCandidate[], offerId: string }>
```
### RondevuAPI Class
Low-level HTTP API client (used internally by Rondevu class).
```typescript
import { RondevuAPI } from '@xtr-dev/rondevu-client'
const api = new RondevuAPI(
baseUrl: string,
username: string,
keypair: Keypair
)
// Check username
await api.checkUsername(username: string): Promise<{
available: boolean
publicKey?: string
claimedAt?: number
expiresAt?: number
}>
// Note: Username claiming is now implicit - usernames are auto-claimed
// on first authenticated request to the server
// ... (all other HTTP endpoints)
```
#### Cryptographic Helpers
```typescript
// Generate Ed25519 keypair
const keypair = await RondevuAPI.generateKeypair(): Promise<Keypair>
// Sign message
const signature = await RondevuAPI.signMessage(
message: string,
privateKey: string
): Promise<string>
// Verify signature
const valid = await RondevuAPI.verifySignature(
message: string,
signature: string,
publicKey: string
): Promise<boolean>
```
---
## Types
```typescript
interface Keypair {
publicKey: string // Base64-encoded Ed25519 public key
privateKey: string // Base64-encoded Ed25519 private key
}
interface Service {
serviceId: string
offers: ServiceOffer[]
username: string
serviceFqn: string
createdAt: number
expiresAt: number
}
interface ServiceOffer {
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
interface IceCandidate {
candidate: RTCIceCandidateInit | null
createdAt: number
}
```
---
## Advanced Usage
### Anonymous Username
```typescript
// Auto-generate anonymous username (format: anon-{timestamp}-{random})
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu'
// No username provided - will generate anonymous username
})
console.log(rondevu.getUsername()) // e.g., "anon-lx2w34-a3f501"
// Anonymous users behave exactly like regular users
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5
})
await rondevu.startFilling()
```
### Persistent Keypair
```typescript
// Save keypair and username to localStorage
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
// Save for later (username will be auto-claimed on first authenticated request)
localStorage.setItem('rondevu-username', rondevu.getUsername())
localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair()))
// Load on next session
const savedUsername = localStorage.getItem('rondevu-username')
const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair'))
const rondevu2 = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: savedUsername,
keypair: savedKeypair
})
```
### Service Discovery
```typescript
// Get a random available service
const service = await rondevu.discoverService('chat:1.0.0')
console.log('Discovered:', service.username)
// Get multiple services (paginated)
const result = await rondevu.discoverServices('chat:1.0.0', 10, 0)
console.log(`Found ${result.count} services:`)
result.services.forEach(s => console.log(` - ${s.username}`))
```
### Multiple Concurrent Offers
```typescript
// Publish service with multiple offers for connection pooling
const offers = []
const connections = []
for (let i = 0; i < 5; i++) {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
offers.push({ sdp: offer.sdp })
connections.push({ pc, dc })
}
const service = await rondevu.publishService({
service: 'chat:1.0.0',
offers,
ttl: 300000
})
// Each offer can be answered independently
console.log(`Published ${service.offers.length} offers`)
```
### Debug Logging
```typescript
// Enable debug logging to see internal operations
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice',
debug: true // All internal logs will be displayed with [Rondevu] prefix
})
// Debug logs include:
// - Connection establishment
// - Keypair generation
// - Service publishing
// - Offer creation
// - ICE candidate exchange
// - Connection state changes
```
---
## Platform Support
### Modern Browsers
Works out of the box - no additional setup needed.
### Node.js 18+
Native fetch is available, but WebRTC requires polyfills:
```bash
npm install wrtc
```
```typescript
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'
// Use wrtc implementations
const pc = new RTCPeerConnection()
```
---
## Username Rules
- **Format**: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`)
- **Length**: 3-32 characters
- **Pattern**: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`
- **Validity**: 365 days from claim/last use
- **Ownership**: Secured by Ed25519 public key signature
---
## Service FQN Format
- **Format**: `service:version@username`
- **Service**: Lowercase alphanumeric + dash (e.g., `chat`, `video-call`)
- **Version**: Semantic versioning (e.g., `1.0.0`, `2.1.3`)
- **Username**: Claimed username
- **Example**: `chat:1.0.0@alice`
---
## Examples
### Node.js Service Host Example
You can host WebRTC services in Node.js that browser clients can connect to:
```typescript
import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'
import wrtc from 'wrtc'
const { RTCPeerConnection } = wrtc
// Initialize with Node crypto adapter
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'mybot',
cryptoAdapter: new NodeCryptoAdapter()
})
// Create peer connection (offerer creates data channel)
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
// Publish service (username auto-claimed on first publish)
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5
})
await rondevu.startFilling()
// Browser clients can now discover and connect to chat:1.0.0@mybot
```
**See also:**
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Complete browser UI ([live](https://ronde.vu))
---
## Migration Guide
### Migration from v0.3.x
v0.4.0 removes high-level abstractions and uses manual WebRTC setup:
**Removed:**
- `ServiceHost` class (use manual WebRTC + `publishService()`)
- `ServiceClient` class (use manual WebRTC + `getService()`)
- `RTCDurableConnection` class (use native WebRTC APIs)
- `RondevuService` class (merged into `Rondevu`)
**Added:**
- `pollOffers()` - Combined polling for answers and ICE candidates
- `publishService()` - Automatic offer pool management
- `connectToService()` - Automatic answering side setup
**Migration Example:**
```typescript
// Before (v0.3.x) - ServiceHost
const host = new ServiceHost({
service: 'chat@1.0.0',
rondevuService: service
})
await host.start()
// After (v0.4.0+) - Automatic setup
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5
})
await rondevu.startFilling()
```

698
README.md
View File

@@ -2,9 +2,9 @@
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
🌐 **WebRTC with durable connections and automatic reconnection**
🌐 **Simple WebRTC signaling client with username-based discovery**
TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing.
TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with username claiming, service publishing/discovery, and efficient batch polling.
**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))
@@ -15,16 +15,17 @@ TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections t
## 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)
- **Username Claiming**: Secure ownership with Ed25519 signatures
- **Anonymous Users**: Auto-generated anonymous usernames for quick testing
- **Service Publishing**: Publish services with multiple offers for connection pooling
- **Service Discovery**: Direct lookup, random discovery, or paginated search
- **Efficient Batch Polling**: Single endpoint for answers and ICE candidates (50% fewer requests)
- **Semantic Version Matching**: Compatible version resolution (chat:1.0.0 matches any 1.x.x)
- **TypeScript**: Full type safety and autocomplete
- **Configurable**: All timeouts, retry limits, and queue sizes are configurable
- **Keypair Management**: Generate or reuse Ed25519 keypairs
- **Automatic Signatures**: All authenticated requests signed automatically
## Install
## Installation
```bash
npm install @xtr-dev/rondevu-client
@@ -32,627 +33,144 @@ npm install @xtr-dev/rondevu-client
## Quick Start
### Publishing a Service (Alice)
### Publishing a Service (Offerer)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { Rondevu } from '@xtr-dev/rondevu-client'
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// 1. Connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice', // Or omit for anonymous username
iceServers: 'ipv4-turn' // Preset: 'ipv4-turn', 'hostname-turns', 'google-stun', 'relay-only'
})
// Step 1: Claim username (one-time)
const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// 2. Publish service with automatic offer management
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (rtcConfig) => {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
// Step 2: Expose service with handler
const keypair = client.usernames.loadKeypairFromStorage('alice');
dc.addEventListener('open', () => {
console.log('Connection opened!')
dc.send('Hello from Alice!')
})
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // Handle 10 concurrent connections
handler: (channel, connectionId) => {
console.log(`📡 New connection: ${connectionId}`);
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
channel.on('message', (data) => {
console.log('📥 Received:', data);
channel.send(`Echo: ${data}`);
});
channel.on('close', () => {
console.log(`👋 Connection ${connectionId} closed`);
});
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
}
});
})
// 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();
// 3. Start accepting connections
await rondevu.startFilling()
```
### Connecting to a Service (Bob)
### Connecting to a Service (Answerer)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { Rondevu } from '@xtr-dev/rondevu-client'
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// 1. Connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'bob',
iceServers: 'ipv4-turn'
})
// Connect to Alice's service
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 5
});
// 2. Connect to service (automatic WebRTC setup)
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc, peerUsername }) => {
console.log('Connected to', peerUsername)
// Create a durable channel
const channel = connection.createChannel('main');
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
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}`);
});
dc.addEventListener('open', () => {
dc.send('Hello from Bob!')
})
}
});
})
// 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();
// Access connection
connection.dc.send('Another message')
connection.pc.close() // Close when done
```
**Service Events:**
```typescript
service.on('published', (serviceId, uuid) => {
console.log(`Service published: ${uuid}`);
});
## Core API
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');
});
```
### Durable Connection API
### Rondevu.connect()
```typescript
// Connect by username and service FQN
const connection = await client.connect('alice', 'chat@1.0.0', {
// Connection options
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();
const rondevu = await Rondevu.connect({
apiUrl: string, // Required: Signaling server URL
username?: string, // Optional: your username (auto-generates anonymous if omitted)
keypair?: Keypair, // Optional: reuse existing keypair
iceServers?: IceServerPreset | RTCIceServer[], // Optional: preset or custom config
debug?: boolean // Optional: enable debug logging (default: false)
})
```
**Connection Events:**
```typescript
connection.on('state', (newState, previousState) => {
console.log(`State: ${previousState}${newState}`);
});
connection.on('connected', () => {
console.log('Connected');
});
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
### Service Publishing
```typescript
const channel = connection.createChannel('chat', {
ordered: true, // optional, default: true
maxRetransmits: undefined // optional, for unordered channels
});
await rondevu.publishService({
service: string, // e.g., 'chat:1.0.0' (username auto-appended)
maxOffers: number, // Maximum concurrent offers to maintain
offerFactory?: OfferFactory, // Optional: custom offer creation
ttl?: number // Optional: offer lifetime in ms (default: 300000)
})
// 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();
await rondevu.startFilling() // Start accepting connections
rondevu.stopFilling() // Stop and close all connections
```
**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
### Service Discovery
```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;
}
// Direct lookup (with username)
await rondevu.getService('chat:1.0.0@alice')
// Random discovery (without username)
await rondevu.discoverService('chat:1.0.0')
// Paginated discovery
await rondevu.discoverServices('chat:1.0.0', limit, offset)
```
### Service Configuration
### Connecting to Services
```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)
}
const connection = await rondevu.connectToService({
serviceFqn?: string, // Full FQN like 'chat:1.0.0@alice'
service?: string, // Service without username (for discovery)
username?: string, // Target username (combined with service)
onConnection?: (context) => void, // Called when data channel opens
rtcConfig?: RTCConfiguration // Optional: override ICE servers
})
```
## Documentation
📚 **[ADVANCED.md](./ADVANCED.md)** - Comprehensive guide including:
- Detailed API reference for all methods
- Type definitions and interfaces
- Platform support (Browser & Node.js)
- Advanced usage patterns
- Username rules and service FQN format
- Examples and migration guides
## 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
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu))
## License

52
eslint.config.js Normal file
View File

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

2964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.9.2",
"version": "0.17.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",
@@ -8,6 +8,10 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "vite",
"lint": "eslint src demo --ext .ts,.tsx,.js",
"lint:fix": "eslint src demo --ext .ts,.tsx,.js --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js}\" \"demo/**/*.{ts,tsx,js,html}\"",
"prepublishOnly": "npm run build"
},
"keywords": [
@@ -20,7 +24,17 @@
"author": "",
"license": "MIT",
"devDependencies": {
"typescript": "^5.9.3"
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"files": [
"dist",

423
src/api.ts Normal file
View File

@@ -0,0 +1,423 @@
/**
* Rondevu API Client - RPC interface
*/
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
import { WebCryptoAdapter } from './web-crypto-adapter.js'
import { RpcBatcher, BatcherOptions } from './rpc-batcher.js'
export type { Keypair } from './crypto-adapter.js'
export type { BatcherOptions } from './rpc-batcher.js'
export interface OfferRequest {
sdp: string
}
export interface ServiceRequest {
serviceFqn: string // Must include username: service:version@username
offers: OfferRequest[]
ttl?: number
signature: string
message: string
}
export interface ServiceOffer {
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface Service {
serviceId: string
offers: ServiceOffer[]
username: string
serviceFqn: string
createdAt: number
expiresAt: number
}
export interface IceCandidate {
candidate: RTCIceCandidateInit | null
createdAt: number
}
/**
* RPC request format
*/
interface RpcRequest {
method: string
message: string
signature: string
publicKey?: string
params?: any
}
/**
* RPC response format
*/
interface RpcResponse {
success: boolean
result?: any
error?: string
}
/**
* RondevuAPI - RPC-based API client for Rondevu signaling server
*/
export class RondevuAPI {
private crypto: CryptoAdapter
private batcher: RpcBatcher | null = null
constructor(
private baseUrl: string,
private username: string,
private keypair: Keypair,
cryptoAdapter?: CryptoAdapter,
batcherOptions?: BatcherOptions | false
) {
// Use WebCryptoAdapter by default (browser environment)
this.crypto = cryptoAdapter || new WebCryptoAdapter()
// Create batcher if not explicitly disabled
if (batcherOptions !== false) {
this.batcher = new RpcBatcher(
(requests) => this.rpcBatchDirect(requests),
batcherOptions
)
}
}
/**
* Generate authentication parameters for RPC calls
*/
private async generateAuth(method: string, params: string = ''): Promise<{
message: string
signature: string
}> {
const timestamp = Date.now()
const message = params
? `${method}:${this.username}:${params}:${timestamp}`
: `${method}:${this.username}:${timestamp}`
const signature = await this.crypto.signMessage(message, this.keypair.privateKey)
return { message, signature }
}
/**
* Execute RPC call with optional batching
*/
private async rpc(request: RpcRequest): Promise<any> {
// Use batcher if enabled
if (this.batcher) {
return await this.batcher.add(request)
}
// Direct call without batching
return await this.rpcDirect(request)
}
/**
* Execute single RPC call directly (bypasses batcher)
*/
private async rpcDirect(request: RpcRequest): Promise<any> {
const response = await fetch(`${this.baseUrl}/rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: RpcResponse = await response.json()
if (!result.success) {
throw new Error(result.error || 'RPC call failed')
}
return result.result
}
/**
* Execute batch RPC calls directly (bypasses batcher)
*/
private async rpcBatchDirect(requests: RpcRequest[]): Promise<any[]> {
const response = await fetch(`${this.baseUrl}/rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requests),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const results: RpcResponse[] = await response.json()
// Validate response is an array
if (!Array.isArray(results)) {
console.error('Invalid RPC batch response:', results)
throw new Error('Server returned invalid batch response (not an array)')
}
// Check response length matches request length
if (results.length !== requests.length) {
console.error(`Response length mismatch: expected ${requests.length}, got ${results.length}`)
}
return results.map((result, i) => {
if (!result || typeof result !== 'object') {
throw new Error(`Invalid response at index ${i}`)
}
if (!result.success) {
throw new Error(result.error || `RPC call ${i} failed`)
}
return result.result
})
}
// ============================================
// Ed25519 Cryptography Helpers
// ============================================
/**
* Generate an Ed25519 keypair for username claiming and service publishing
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/
static async generateKeypair(cryptoAdapter?: CryptoAdapter): Promise<Keypair> {
const adapter = cryptoAdapter || new WebCryptoAdapter()
return await adapter.generateKeypair()
}
/**
* Sign a message with an Ed25519 private key
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/
static async signMessage(
message: string,
privateKeyBase64: string,
cryptoAdapter?: CryptoAdapter
): Promise<string> {
const adapter = cryptoAdapter || new WebCryptoAdapter()
return await adapter.signMessage(message, privateKeyBase64)
}
/**
* Verify an Ed25519 signature
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string,
cryptoAdapter?: CryptoAdapter
): Promise<boolean> {
const adapter = cryptoAdapter || new WebCryptoAdapter()
return await adapter.verifySignature(message, signatureBase64, publicKeyBase64)
}
// ============================================
// Username Management
// ============================================
/**
* Check if a username is available
*/
async isUsernameAvailable(username: string): Promise<boolean> {
const auth = await this.generateAuth('getUser', username)
const result = await this.rpc({
method: 'getUser',
message: auth.message,
signature: auth.signature,
params: { username },
})
return result.available
}
/**
* Check if current username is claimed
*/
async isUsernameClaimed(): Promise<boolean> {
const auth = await this.generateAuth('getUser', this.username)
const result = await this.rpc({
method: 'getUser',
message: auth.message,
signature: auth.signature,
params: { username: this.username },
})
return !result.available
}
// ============================================
// Service Management
// ============================================
/**
* Publish a service
*/
async publishService(service: ServiceRequest): Promise<Service> {
const auth = await this.generateAuth('publishService', service.serviceFqn)
return await this.rpc({
method: 'publishService',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: {
serviceFqn: service.serviceFqn,
offers: service.offers,
ttl: service.ttl,
},
})
}
/**
* Get service by FQN (direct lookup, random, or paginated)
*/
async getService(
serviceFqn: string,
options?: { limit?: number; offset?: number }
): Promise<any> {
const auth = await this.generateAuth('getService', serviceFqn)
return await this.rpc({
method: 'getService',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: {
serviceFqn,
...options,
},
})
}
/**
* Delete a service
*/
async deleteService(serviceFqn: string): Promise<void> {
const auth = await this.generateAuth('deleteService', serviceFqn)
await this.rpc({
method: 'deleteService',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { serviceFqn },
})
}
// ============================================
// WebRTC Signaling
// ============================================
/**
* Answer an offer
*/
async answerOffer(serviceFqn: string, offerId: string, sdp: string): Promise<void> {
const auth = await this.generateAuth('answerOffer', offerId)
await this.rpc({
method: 'answerOffer',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { serviceFqn, offerId, sdp },
})
}
/**
* Get answer for a specific offer (offerer polls this)
*/
async getOfferAnswer(
serviceFqn: string,
offerId: string
): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
try {
const auth = await this.generateAuth('getOfferAnswer', offerId)
return await this.rpc({
method: 'getOfferAnswer',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { serviceFqn, offerId },
})
} catch (err) {
if ((err as Error).message.includes('not yet answered')) {
return null
}
throw err
}
}
/**
* Combined polling for answers and ICE candidates
*/
async poll(since?: number): Promise<{
answers: Array<{
offerId: string
serviceId?: string
answererId: string
sdp: string
answeredAt: number
}>
iceCandidates: Record<
string,
Array<{
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
peerId: string
createdAt: number
}>
>
}> {
const auth = await this.generateAuth('poll')
return await this.rpc({
method: 'poll',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { since },
})
}
/**
* Add ICE candidates to a specific offer
*/
async addOfferIceCandidates(
serviceFqn: string,
offerId: string,
candidates: RTCIceCandidateInit[]
): Promise<{ count: number; offerId: string }> {
const auth = await this.generateAuth('addIceCandidates', offerId)
return await this.rpc({
method: 'addIceCandidates',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { serviceFqn, offerId, candidates },
})
}
/**
* Get ICE candidates for a specific offer
*/
async getOfferIceCandidates(
serviceFqn: string,
offerId: string,
since: number = 0
): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const auth = await this.generateAuth('getIceCandidates', `${offerId}:${since}`)
const result = await this.rpc({
method: 'getIceCandidates',
message: auth.message,
signature: auth.signature,
publicKey: this.keypair.publicKey,
params: { serviceFqn, offerId, since },
})
return {
candidates: result.candidates || [],
offerId: result.offerId,
}
}
}

View File

@@ -1,62 +0,0 @@
export interface Credentials {
peerId: string;
secret: string;
}
// Fetch-compatible function type
export type FetchFunction = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Response>;
export class RondevuAuth {
private fetchFn: FetchFunction;
constructor(
private baseUrl: string,
fetchFn?: FetchFunction
) {
// Use provided fetch or fall back to global fetch
this.fetchFn = fetchFn || ((...args) => {
if (typeof globalThis.fetch === 'function') {
return globalThis.fetch(...args);
}
throw new Error(
'fetch is not available. Please provide a fetch implementation in the constructor options.'
);
});
}
/**
* Register a new peer and receive credentials
* 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}`;
}
}

48
src/crypto-adapter.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Crypto adapter interface for platform-independent cryptographic operations
*/
export interface Keypair {
publicKey: string
privateKey: string
}
/**
* Platform-independent crypto adapter interface
* Implementations provide platform-specific crypto operations
*/
export interface CryptoAdapter {
/**
* Generate an Ed25519 keypair
*/
generateKeypair(): Promise<Keypair>
/**
* Sign a message with an Ed25519 private key
*/
signMessage(message: string, privateKeyBase64: string): Promise<string>
/**
* Verify an Ed25519 signature
*/
verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean>
/**
* Convert Uint8Array to base64 string
*/
bytesToBase64(bytes: Uint8Array): string
/**
* Convert base64 string to Uint8Array
*/
base64ToBytes(base64: string): Uint8Array
/**
* Generate random bytes
*/
randomBytes(length: number): Uint8Array
}

View File

@@ -1,361 +0,0 @@
/**
* 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;
}
}

View File

@@ -1,453 +0,0 @@
/**
* 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();
}
}

View File

@@ -1,200 +0,0 @@
/**
* 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
};
}

View File

@@ -1,329 +0,0 @@
/**
* 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');
}
}
}

View File

@@ -1,184 +0,0 @@
/**
* 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,109 +0,0 @@
/**
* 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<EventMap extends Record<string, (...args: any[]) => void>> {
private events: Map<keyof EventMap, Set<Function>> = new Map();
/**
* Register an event listener
*/
on<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(listener);
return this;
}
/**
* Register a one-time event listener
*/
once<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
const onceWrapper = (...args: Parameters<EventMap[K]>) => {
this.off(event, onceWrapper as EventMap[K]);
listener(...args);
};
return this.on(event, onceWrapper as EventMap[K]);
}
/**
* Remove an event listener
*/
off<K extends keyof EventMap>(event: K, listener: EventMap[K]): this {
const listeners = this.events.get(event);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.events.delete(event);
}
}
return this;
}
/**
* Emit an event
*/
protected emit<K extends keyof EventMap>(
event: K,
...args: Parameters<EventMap[K]>
): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.size === 0) {
return false;
}
listeners.forEach(listener => {
try {
(listener as EventMap[K])(...args);
} catch (err) {
console.error(`Error in ${String(event)} event listener:`, err);
}
});
return true;
}
/**
* Remove all listeners for an event (or all events if not specified)
*/
removeAllListeners<K extends keyof EventMap>(event?: K): this {
if (event !== undefined) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
/**
* Get listener count for an event
*/
listenerCount<K extends keyof EventMap>(event: K): number {
const listeners = this.events.get(event);
return listeners ? listeners.size : 0;
}
}

View File

@@ -1,36 +1,39 @@
/**
* @xtr-dev/rondevu-client
* WebRTC peer signaling and discovery client with durable connections
* WebRTC peer signaling client
*/
// Export main client class
export { Rondevu } from './rondevu.js';
export type { RondevuOptions } from './rondevu.js';
export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js'
// Export authentication
export { RondevuAuth } from './auth.js';
export type { Credentials, FetchFunction } from './auth.js';
// Export crypto adapters
export { WebCryptoAdapter } from './web-crypto-adapter.js'
export { NodeCryptoAdapter } from './node-crypto-adapter.js'
// Export username API
export { RondevuUsername } from './usernames.js';
export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js';
// 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 types
export type {
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
QueuedMessage,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo
} from './durable/types.js';
Signaler,
Binnable,
} from './types.js'
export type {
Keypair,
OfferRequest,
ServiceRequest,
Service,
ServiceOffer,
IceCandidate,
} from './api.js'
export type {
RondevuOptions,
PublishServiceOptions,
ConnectToServiceOptions,
ConnectionContext,
OfferContext,
OfferFactory
} from './rondevu.js'
export type { CryptoAdapter } from './crypto-adapter.js'

View File

@@ -0,0 +1,98 @@
/**
* Node.js Crypto adapter for Node.js environments
* Requires Node.js 19+ or Node.js 18 with --experimental-global-webcrypto flag
*/
import * as ed25519 from '@noble/ed25519'
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
/**
* Node.js Crypto implementation using Node.js built-in APIs
* Uses Buffer for base64 encoding and crypto.randomBytes for random generation
*
* Requirements:
* - Node.js 19+ (crypto.subtle available globally)
* - OR Node.js 18 with --experimental-global-webcrypto flag
*
* @example
* ```typescript
* import { RondevuAPI } from '@xtr-dev/rondevu-client'
* import { NodeCryptoAdapter } from '@xtr-dev/rondevu-client/node'
*
* const api = new RondevuAPI(
* 'https://signal.example.com',
* 'alice',
* keypair,
* new NodeCryptoAdapter()
* )
* ```
*/
export class NodeCryptoAdapter implements CryptoAdapter {
constructor() {
// Set SHA-512 hash function for ed25519 using Node's crypto.subtle
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error(
'crypto.subtle is not available. ' +
'Node.js 19+ is required, or Node.js 18 with --experimental-global-webcrypto flag'
)
}
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
const hash = await crypto.subtle.digest('SHA-512', message as BufferSource)
return new Uint8Array(hash)
}
}
async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: this.bytesToBase64(publicKey),
privateKey: this.bytesToBase64(privateKey),
}
}
async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = this.base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return this.bytesToBase64(signature)
}
async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
try {
const signature = this.base64ToBytes(signatureBase64)
const publicKey = this.base64ToBytes(publicKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
} catch {
return false
}
}
bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return Buffer.from(bytes).toString('base64')
}
base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return new Uint8Array(Buffer.from(base64, 'base64'))
}
randomBytes(length: number): Uint8Array {
// Use Web Crypto API's getRandomValues (available in Node 19+)
return crypto.getRandomValues(new Uint8Array(length))
}
}

View File

@@ -1,205 +0,0 @@
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;
}
}

View File

@@ -1,321 +0,0 @@
import { Credentials, FetchFunction } from './auth.js';
import { RondevuAuth } from './auth.js';
// Declare Buffer for Node.js compatibility
declare const Buffer: any;
export interface CreateOfferRequest {
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

@@ -1,49 +0,0 @@
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;
}
}
}

View File

@@ -1,12 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -1,83 +0,0 @@
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);
}
}

View File

@@ -1,18 +0,0 @@
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();
}
}

View File

@@ -1,18 +0,0 @@
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);
}
}

View File

@@ -1,237 +0,0 @@
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();
}
}

View File

@@ -1,73 +0,0 @@
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));
}
}

View File

@@ -1,45 +0,0 @@
/**
* 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

@@ -1,78 +0,0 @@
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);
}
}

File diff suppressed because it is too large Load Diff

157
src/rpc-batcher.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* RPC Batcher - Throttles and batches RPC requests to reduce HTTP overhead
*/
export interface BatcherOptions {
/**
* Maximum number of requests to batch together
* Default: 10
*/
maxBatchSize?: number
/**
* Maximum time to wait before sending a batch (ms)
* Default: 50ms
*/
maxWaitTime?: number
/**
* Minimum time between batches (ms)
* Default: 10ms
*/
throttleInterval?: number
}
interface QueuedRequest {
request: any
resolve: (value: any) => void
reject: (error: Error) => void
}
/**
* Batches and throttles RPC requests to optimize network usage
*
* @example
* ```typescript
* const batcher = new RpcBatcher(
* (requests) => api.rpcBatch(requests),
* { maxBatchSize: 10, maxWaitTime: 50 }
* )
*
* // These will be batched together if called within maxWaitTime
* const result1 = await batcher.add(request1)
* const result2 = await batcher.add(request2)
* const result3 = await batcher.add(request3)
* ```
*/
export class RpcBatcher {
private queue: QueuedRequest[] = []
private batchTimeout: ReturnType<typeof setTimeout> | null = null
private lastBatchTime: number = 0
private options: Required<BatcherOptions>
private sendBatch: (requests: any[]) => Promise<any[]>
constructor(
sendBatch: (requests: any[]) => Promise<any[]>,
options?: BatcherOptions
) {
this.sendBatch = sendBatch
this.options = {
maxBatchSize: options?.maxBatchSize ?? 10,
maxWaitTime: options?.maxWaitTime ?? 50,
throttleInterval: options?.throttleInterval ?? 10,
}
}
/**
* Add an RPC request to the batch queue
* Returns a promise that resolves when the request completes
*/
async add(request: any): Promise<any> {
return new Promise((resolve, reject) => {
this.queue.push({ request, resolve, reject })
// Send immediately if batch is full
if (this.queue.length >= this.options.maxBatchSize) {
this.flush()
return
}
// Schedule batch if not already scheduled
if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => {
this.flush()
}, this.options.maxWaitTime)
}
})
}
/**
* Flush the queue immediately
*/
async flush(): Promise<void> {
// Clear timeout if set
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
// Nothing to flush
if (this.queue.length === 0) {
return
}
// Throttle: wait if we sent a batch too recently
const now = Date.now()
const timeSinceLastBatch = now - this.lastBatchTime
if (timeSinceLastBatch < this.options.throttleInterval) {
const waitTime = this.options.throttleInterval - timeSinceLastBatch
await new Promise(resolve => setTimeout(resolve, waitTime))
}
// Extract requests from queue
const batch = this.queue.splice(0, this.options.maxBatchSize)
const requests = batch.map(item => item.request)
this.lastBatchTime = Date.now()
try {
// Send batch request
const results = await this.sendBatch(requests)
// Resolve individual promises
for (let i = 0; i < batch.length; i++) {
batch[i].resolve(results[i])
}
} catch (error) {
// Reject all promises in batch
for (const item of batch) {
item.reject(error as Error)
}
}
}
/**
* Get current queue size
*/
getQueueSize(): number {
return this.queue.length
}
/**
* Clear the queue without sending
*/
clear(): void {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout)
this.batchTimeout = null
}
// Reject all pending requests
for (const item of this.queue) {
item.reject(new Error('Batch queue cleared'))
}
this.queue = []
}
}

View File

@@ -1,664 +0,0 @@
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 = [];
const pendingCandidates: RTCIceCandidateInit[][] = []; // Store candidates before we have offer IDs
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);
// Set up temporary candidate collector BEFORE setLocalDescription
const candidatesForThisOffer: RTCIceCandidateInit[] = [];
pendingCandidates.push(candidatesForThisOffer);
pc.onicecandidate = (event) => {
if (event.candidate) {
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(`🧊 Service pool generated ${type} ICE candidate:`, candidateData.candidate);
candidatesForThisOffer.push(candidateData);
}
} else {
console.log('🧊 Service pool ICE gathering complete');
}
};
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer); // ICE gathering starts here, candidates go to collector
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);
// Now send all pending candidates and set up handlers for future ones
for (let i = 0; i < peerConnections.length; i++) {
const pc = peerConnections[i];
const offerId = createdOffers[i].id;
const candidates = pendingCandidates[i];
// Send any candidates that were collected while waiting for offer ID
if (candidates.length > 0) {
console.log(`📤 Sending ${candidates.length} pending ICE candidate(s) for offer ${offerId}`);
try {
await this.offersApi.addIceCandidates(offerId, candidates);
console.log(`✅ Sent ${candidates.length} pending ICE candidate(s)`);
} catch (err) {
console.error('❌ Error sending pending ICE candidates:', err);
}
}
// Replace temporary handler with permanent one for any future candidates
pc.onicecandidate = async (event) => {
if (event.candidate) {
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(`🧊 Service pool generated late ${type} ICE candidate:`, candidateData.candidate);
try {
await this.offersApi.addIceCandidates(offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} catch (err) {
console.error(`❌ Error sending ${type} 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');
// Collect candidates before we have offer ID
const pendingCandidates: RTCIceCandidateInit[] = [];
// Set up temporary candidate collector BEFORE setLocalDescription
pc.onicecandidate = (event) => {
if (event.candidate) {
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(`🧊 Initial service generated ${type} ICE candidate:`, candidateData.candidate);
pendingCandidates.push(candidateData);
}
} else {
console.log('🧊 Initial service ICE gathering complete');
}
};
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer); // ICE gathering starts here
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();
// Send any pending candidates
if (pendingCandidates.length > 0) {
console.log(`📤 Sending ${pendingCandidates.length} pending ICE candidate(s) for initial service`);
try {
await this.offersApi.addIceCandidates(data.offerId, pendingCandidates);
console.log(`✅ Sent ${pendingCandidates.length} pending ICE candidate(s)`);
} catch (err) {
console.error('❌ Error sending pending ICE candidates:', err);
}
}
// Set up handler for any future candidates
pc.onicecandidate = async (event) => {
if (event.candidate) {
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(`🧊 Initial service generated late ${type} ICE candidate:`, candidateData.candidate);
try {
await this.offersApi.addIceCandidates(data.offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} catch (err) {
console.error(`❌ Error sending ${type} 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)}`;
}
}

20
src/types.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Core signaling types
*/
/**
* Cleanup function returned by listener methods
*/
export type Binnable = () => void
/**
* Signaler interface for WebRTC offer/answer/ICE exchange
*/
export interface Signaler {
addIceCandidate(candidate: RTCIceCandidate): Promise<void>
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
}

View File

@@ -1,200 +0,0 @@
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 };
}
}

67
src/web-crypto-adapter.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Web Crypto adapter for browser environments
*/
import * as ed25519 from '@noble/ed25519'
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
}
/**
* Web Crypto implementation using browser APIs
* Uses btoa/atob for base64 encoding and crypto.getRandomValues for random bytes
*/
export class WebCryptoAdapter implements CryptoAdapter {
async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: this.bytesToBase64(publicKey),
privateKey: this.bytesToBase64(privateKey),
}
}
async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = this.base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return this.bytesToBase64(signature)
}
async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
try {
const signature = this.base64ToBytes(signatureBase64)
const publicKey = this.base64ToBytes(publicKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
} catch {
return false
}
}
bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
}
base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
}
randomBytes(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length))
}
}