32 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
13 changed files with 1957 additions and 1577 deletions

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()
```

513
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)
🌐 **Simple, high-level WebRTC peer-to-peer connections**
🌐 **Simple WebRTC signaling client with username-based discovery**
TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connections with automatic signaling, username-based discovery, and built-in reconnection support.
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,18 +15,17 @@ TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connectio
## Features
- **High-Level Wrappers**: ServiceHost and ServiceClient eliminate WebRTC boilerplate
- **Username-Based Discovery**: Connect to peers by username, not complex offer/answer exchange
- **Semver-Compatible Matching**: Requesting chat@1.0.0 matches any compatible 1.x.x version
- **Privacy-First Design**: Services are hidden by default - no enumeration possible
- **Automatic Reconnection**: Built-in retry logic with exponential backoff
- **Message Queuing**: Messages sent while disconnected are queued and flushed on reconnect
- **Cryptographic Username Claiming**: Secure ownership with Ed25519 signatures
- **Service Publishing**: Package-style naming (chat.app@1.0.0) with multiple simultaneous offers
- **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 Polling**: Exponential backoff with jitter to reduce server load
- **Keypair Management**: Generate or reuse Ed25519 keypairs
- **Automatic Signatures**: All authenticated requests signed automatically
## Install
## Installation
```bash
npm install @xtr-dev/rondevu-client
@@ -34,424 +33,144 @@ npm install @xtr-dev/rondevu-client
## Quick Start
### Hosting a Service (Alice)
### Publishing a Service (Offerer)
```typescript
import { RondevuService, ServiceHost } from '@xtr-dev/rondevu-client'
import { Rondevu } from '@xtr-dev/rondevu-client'
// Step 1: Create and initialize service
const service = new RondevuService({
// 1. Connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
username: 'alice', // Or omit for anonymous username
iceServers: 'ipv4-turn' // Preset: 'ipv4-turn', 'hostname-turns', 'google-stun', 'relay-only'
})
await service.initialize() // Generates keypair
await service.claimUsername() // Claims username with signature
// 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: Create ServiceHost
const host = new ServiceHost({
service: 'chat.app@1.0.0',
rondevuService: service,
maxPeers: 5, // Accept up to 5 connections
ttl: 300000 // 5 minutes
dc.addEventListener('open', () => {
console.log('Connection opened!')
dc.send('Hello from Alice!')
})
// Step 3: Listen for incoming connections
host.events.on('connection', (connection) => {
console.log('✅ New connection!')
connection.events.on('message', (msg) => {
console.log('📨 Received:', msg)
connection.sendMessage('Hello from Alice!')
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
connection.events.on('state-change', (state) => {
console.log('Connection state:', state)
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
}
})
host.events.on('error', (error) => {
console.error('Host error:', error)
})
// Step 4: Start hosting
await host.start()
console.log('Service is now live! Others can connect to @alice')
// Later: stop hosting
host.dispose()
// 3. Start accepting connections
await rondevu.startFilling()
```
### Connecting to a Service (Bob)
### Connecting to a Service (Answerer)
```typescript
import { RondevuService, ServiceClient } from '@xtr-dev/rondevu-client'
import { Rondevu } from '@xtr-dev/rondevu-client'
// Step 1: Create and initialize service
const service = new RondevuService({
// 1. Connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'bob'
username: 'bob',
iceServers: 'ipv4-turn'
})
await service.initialize()
await service.claimUsername()
// 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)
// Step 2: Create ServiceClient
const client = new ServiceClient({
username: 'alice', // Connect to Alice
serviceFqn: 'chat.app@1.0.0',
rondevuService: service,
autoReconnect: true,
maxReconnectAttempts: 5
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
// Step 3: Listen for connection events
client.events.on('connected', (connection) => {
console.log('✅ Connected to Alice!')
connection.events.on('message', (msg) => {
console.log('📨 Received:', msg)
dc.addEventListener('open', () => {
dc.send('Hello from Bob!')
})
// Send a message
connection.sendMessage('Hello from Bob!')
})
client.events.on('disconnected', () => {
console.log('🔌 Disconnected')
})
client.events.on('reconnecting', ({ attempt, maxAttempts }) => {
console.log(`🔄 Reconnecting (${attempt}/${maxAttempts})...`)
})
client.events.on('error', (error) => {
console.error('❌ Error:', error)
})
// Step 4: Connect
await client.connect()
// Later: disconnect
client.dispose()
```
## Core Concepts
### RondevuService
Handles authentication and username management:
- Generates Ed25519 keypair for signing
- Claims usernames with cryptographic proof
- Provides API client for signaling server
### ServiceHost
High-level wrapper for hosting a WebRTC service:
- Automatically creates and publishes offers
- Handles incoming connections
- Manages ICE candidate exchange
- Supports multiple simultaneous peers
### ServiceClient
High-level wrapper for connecting to services:
- Discovers services by username
- Handles offer/answer exchange automatically
- Built-in auto-reconnection with exponential backoff
- Event-driven API
### RTCDurableConnection
Low-level connection wrapper (used internally):
- Manages WebRTC PeerConnection lifecycle
- Handles ICE candidate polling
- Provides message queue for reliability
- State management and events
## API Reference
### RondevuService
```typescript
const service = new RondevuService({
apiUrl: string, // Signaling server URL
username: string, // Your username
keypair?: Keypair // Optional: reuse existing keypair
})
// Initialize service (generates keypair if not provided)
await service.initialize(): Promise<void>
// Claim username with cryptographic signature
await service.claimUsername(): Promise<void>
// Check if username is claimed
service.isUsernameClaimed(): boolean
// Get current username
service.getUsername(): string
// Get keypair
service.getKeypair(): Keypair
// Get API client
service.getAPI(): RondevuAPI
```
### ServiceHost
```typescript
const host = new ServiceHost({
service: string, // Service FQN (e.g., 'chat.app@1.0.0')
rondevuService: RondevuService,
maxPeers?: number, // Default: 5
ttl?: number, // Default: 300000 (5 minutes)
isPublic?: boolean, // Default: true
rtcConfiguration?: RTCConfiguration
})
// Start hosting
await host.start(): Promise<void>
// Stop hosting and cleanup
host.dispose(): void
// Get all active connections
host.getConnections(): RTCDurableConnection[]
// Events
host.events.on('connection', (conn: RTCDurableConnection) => {})
host.events.on('error', (error: Error) => {})
```
### ServiceClient
```typescript
const client = new ServiceClient({
username: string, // Host username to connect to
serviceFqn: string, // Service FQN (e.g., 'chat.app@1.0.0')
rondevuService: RondevuService,
autoReconnect?: boolean, // Default: true
maxReconnectAttempts?: number, // Default: 5
rtcConfiguration?: RTCConfiguration
})
// Connect to service
await client.connect(): Promise<RTCDurableConnection>
// Disconnect and cleanup
client.dispose(): void
// Get current connection
client.getConnection(): RTCDurableConnection | null
// Events
client.events.on('connected', (conn: RTCDurableConnection) => {})
client.events.on('disconnected', () => {})
client.events.on('reconnecting', (info: { attempt: number, maxAttempts: number }) => {})
client.events.on('error', (error: Error) => {})
```
### RTCDurableConnection
```typescript
// Connection state
connection.state: 'connected' | 'connecting' | 'disconnected'
// Send message (returns true if sent, false if queued)
await connection.sendMessage(message: string): Promise<boolean>
// Queue message for sending when connected
await connection.queueMessage(message: string, options?: QueueMessageOptions): Promise<void>
// Disconnect
connection.disconnect(): void
// Events
connection.events.on('message', (msg: string) => {})
connection.events.on('state-change', (state: ConnectionStates) => {})
```
## Configuration
### Polling Configuration
The signaling uses configurable polling with exponential backoff:
```typescript
// Default polling config
{
initialInterval: 500, // Start at 500ms
maxInterval: 5000, // Max 5 seconds
backoffMultiplier: 1.5, // Increase by 1.5x each time
maxRetries: 50, // Max 50 attempts
jitter: true // Add random 0-100ms to prevent thundering herd
}
```
This is handled automatically - no configuration needed.
### WebRTC Configuration
Provide custom STUN/TURN servers:
```typescript
const host = new ServiceHost({
service: 'chat.app@1.0.0',
rondevuService: service,
rtcConfiguration: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
]
}
})
// Access connection
connection.dc.send('Another message')
connection.pc.close() // Close when done
```
## Username Rules
## Core API
- **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
### Rondevu.connect()
```typescript
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)
})
```
### Service Publishing
```typescript
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)
})
await rondevu.startFilling() // Start accepting connections
rondevu.stopFilling() // Stop and close all connections
```
### Service Discovery
```typescript
// 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)
```
### Connecting to Services
```typescript
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
See [demo/demo.js](./demo/demo.js) for a complete working example.
### Persistent Keypair
```typescript
// Save keypair to localStorage
const service = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
await service.initialize()
await service.claimUsername()
// Save for later
localStorage.setItem('rondevu-keypair', JSON.stringify(service.getKeypair()))
localStorage.setItem('rondevu-username', service.getUsername())
// Load on next session
const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair'))
const savedUsername = localStorage.getItem('rondevu-username')
const service2 = new RondevuService({
apiUrl: 'https://api.ronde.vu',
username: savedUsername,
keypair: savedKeypair
})
await service2.initialize() // Reuses keypair
```
### Message Queue Example
```typescript
// Messages are automatically queued if not connected yet
client.events.on('connected', (connection) => {
// Send immediately
connection.sendMessage('Hello!')
})
// Or queue for later
await client.connect()
const conn = client.getConnection()
await conn.queueMessage('This will be sent when connected', {
expiresAt: Date.now() + 60000 // Expire after 1 minute
})
```
## Migration from v0.9.x
v0.11.0+ introduces high-level wrappers, RESTful API changes, and semver-compatible discovery:
**API Changes:**
- Server endpoints restructured (`/usernames/*``/users/*`)
- Added `ServiceHost` and `ServiceClient` wrappers
- Message queue fully implemented
- Configurable polling with exponential backoff
- Removed deprecated `cleanup()` methods (use `dispose()`)
- **v0.11.0+**: Services use `offers` array instead of single `sdp`
- **v0.11.0+**: Semver-compatible service discovery (chat@1.0.0 matches 1.x.x)
- **v0.11.0+**: All services are hidden - no listing endpoint
- **v0.11.0+**: Services support multiple simultaneous offers for connection pooling
**Migration Guide:**
```typescript
// Before (v0.9.x) - Manual WebRTC setup
const signaler = new RondevuSignaler(service, 'chat@1.0.0')
const context = new WebRTCContext()
const pc = context.createPeerConnection()
// ... 50+ lines of boilerplate
// After (v0.11.0) - ServiceHost wrapper
const host = new ServiceHost({
service: 'chat@1.0.0',
rondevuService: service
})
await host.start()
// Done!
```
## 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 { WebRTCContext } from '@xtr-dev/rondevu-client'
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'
// Configure WebRTC context
const context = new WebRTCContext({
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
} as any)
```
## TypeScript
All types are exported:
```typescript
import type {
RondevuServiceOptions,
ServiceHostOptions,
ServiceHostEvents,
ServiceClientOptions,
ServiceClientEvents,
ConnectionInterface,
ConnectionEvents,
ConnectionStates,
Message,
QueueMessageOptions,
Signaler,
PollingConfig,
Credentials,
Keypair
} from '@xtr-dev/rondevu-client'
```
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu))
## License

281
USAGE.md
View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.13.0",
"version": "0.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.13.0",
"version": "0.17.0",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.13.0",
"version": "0.17.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",

View File

@@ -1,41 +1,13 @@
/**
* Rondevu API Client - Single class for all API endpoints
* Rondevu API Client - RPC interface
*/
import * as ed25519 from '@noble/ed25519'
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
import { WebCryptoAdapter } from './web-crypto-adapter.js'
import { RpcBatcher, BatcherOptions } from './rpc-batcher.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))
}
export interface Credentials {
peerId: string
secret: string
}
export interface Keypair {
publicKey: string
privateKey: string
}
export interface OfferRequest {
sdp: string
topics?: string[]
ttl?: number
secret?: string
}
export interface Offer {
id: string
peerId: string
sdp: string
topics: string[]
ttl: number
createdAt: number
expiresAt: number
answererPeerId?: string
}
export type { Keypair } from './crypto-adapter.js'
export type { BatcherOptions } from './rpc-batcher.js'
export interface OfferRequest {
sdp: string
@@ -66,52 +38,145 @@ export interface Service {
}
export interface IceCandidate {
candidate: RTCIceCandidateInit
candidate: RTCIceCandidateInit | null
createdAt: number
}
/**
* Helper: Convert Uint8Array to base64 string
* RPC request format
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
interface RpcRequest {
method: string
message: string
signature: string
publicKey?: string
params?: any
}
/**
* Helper: Convert base64 string to Uint8Array
* RPC response format
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
interface RpcResponse {
success: boolean
result?: any
error?: string
}
/**
* RondevuAPI - Complete API client for Rondevu signaling server
* 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 credentials?: Credentials
) {}
private username: string,
private keypair: Keypair,
cryptoAdapter?: CryptoAdapter,
batcherOptions?: BatcherOptions | false
) {
// Use WebCryptoAdapter by default (browser environment)
this.crypto = cryptoAdapter || new WebCryptoAdapter()
/**
* Set credentials for authentication
*/
setCredentials(credentials: Credentials): void {
this.credentials = credentials
// Create batcher if not explicitly disabled
if (batcherOptions !== false) {
this.batcher = new RpcBatcher(
(requests) => this.rpcBatchDirect(requests),
batcherOptions
)
}
}
/**
* Authentication header
* Generate authentication parameters for RPC calls
*/
private getAuthHeader(): Record<string, string> {
if (!this.credentials) {
return {}
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 }
}
return {
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
/**
* 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
})
}
// ============================================
@@ -120,339 +185,239 @@ export class RondevuAPI {
/**
* Generate an Ed25519 keypair for username claiming and service publishing
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/
static async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
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): 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)
static async signMessage(
message: string,
privateKeyBase64: string,
cryptoAdapter?: CryptoAdapter
): Promise<string> {
const adapter = cryptoAdapter || new WebCryptoAdapter()
return await adapter.signMessage(message, privateKeyBase64)
}
/**
* Verify a signature
* Verify an Ed25519 signature
* @param cryptoAdapter - Optional crypto adapter (defaults to WebCryptoAdapter)
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
publicKeyBase64: string,
cryptoAdapter?: CryptoAdapter
): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64)
const signature = base64ToBytes(signatureBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
const adapter = cryptoAdapter || new WebCryptoAdapter()
return await adapter.verifySignature(message, signatureBase64, publicKeyBase64)
}
// ============================================
// Authentication
// Username Management
// ============================================
/**
* Register a new peer and get credentials
* Check if a username is available
*/
async register(): Promise<Credentials> {
const response = await fetch(`${this.baseUrl}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Registration failed: ${error.error || response.statusText}`)
return result.available
}
return await response.json()
/**
* 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
}
// ============================================
// Offers
// Service Management
// ============================================
/**
* Create one or more offers
* Publish a service
*/
async createOffers(offers: OfferRequest[]): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
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,
},
body: JSON.stringify({ offers }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get offer by ID
* Get service by FQN (direct lookup, random, or paginated)
*/
async getOffer(offerId: string): Promise<Offer> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Answer a specific offer from a service
*/
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
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,
},
body: JSON.stringify({ sdp }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
}
return await response.json()
/**
* 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> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
headers: this.getAuthHeader(),
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 },
})
if (!response.ok) {
// 404 means not yet answered
if (response.status === 404) {
} catch (err) {
if ((err as Error).message.includes('not yet answered')) {
return null
}
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
throw err
}
return await response.json()
}
/**
* Search offers by topic
* Combined polling for answers and ICE candidates
*/
async searchOffers(topic: string): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
headers: this.getAuthHeader(),
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 },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// ICE Candidates
// ============================================
/**
* Add ICE candidates to a specific offer
*/
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ candidates }),
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 },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get ICE candidates for a specific offer (with polling support)
* Get ICE candidates for a specific offer
*/
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`)
url.searchParams.set('since', since.toString())
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 },
})
const response = await fetch(url.toString(), { headers: this.getAuthHeader() })
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
}
const data = await response.json()
return {
candidates: data.candidates || [],
offerId: data.offerId
candidates: result.candidates || [],
offerId: result.offerId,
}
}
// ============================================
// Services
// ============================================
/**
* Publish a service
* Service FQN must include username: service:version@username
*/
async publishService(service: ServiceRequest): Promise<Service> {
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify(service),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to discover service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`)
url.searchParams.set('limit', limit.toString())
url.searchParams.set('offset', offset.toString())
const response = await fetch(url.toString(), {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to discover services: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// Usernames
// ============================================
/**
* Check if username is available
*/
async checkUsername(username: string): Promise<{ available: boolean; publicKey?: string; claimedAt?: number; expiresAt?: number }> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}`
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Claim a username (requires Ed25519 signature)
*/
async claimUsername(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ success: boolean; username: string }> {
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({
publicKey,
signature,
message,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
}
return await response.json()
}
}

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

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

View File

@@ -1,23 +1,109 @@
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest, IceCandidate } from './api.js'
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
// ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
// ICE server presets
export const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]> = {
'ipv4-turn': [
{ urls: 'stun:57.129.61.67:3478' },
{
urls: [
'turn:57.129.61.67:3478?transport=tcp',
'turn:57.129.61.67:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword'
}
],
'hostname-turns': [
{ urls: 'stun:turn.share.fish:3478' },
{
urls: [
'turns:turn.share.fish:5349?transport=tcp',
'turns:turn.share.fish:5349?transport=udp',
'turn:turn.share.fish:3478?transport=tcp',
'turn:turn.share.fish:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword'
}
],
'google-stun': [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
],
'relay-only': [
{ urls: 'stun:57.129.61.67:3478' },
{
urls: [
'turn:57.129.61.67:3478?transport=tcp',
'turn:57.129.61.67:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword',
// @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
iceTransportPolicy: 'relay'
}
]
}
export interface RondevuOptions {
apiUrl: string
username: string
keypair?: Keypair
credentials?: Credentials
username?: string // Optional, will generate anonymous if not provided
keypair?: Keypair // Optional, will generate if not provided
cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter
batching?: BatcherOptions | false // Optional, defaults to enabled with default options
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
debug?: boolean // Optional: enable debug logging (default: false)
}
export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel
offer: RTCSessionDescriptionInit
}
export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>
export interface PublishServiceOptions {
serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice")
offers: Array<{ sdp: string }>
ttl?: number
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
maxOffers: number // Maximum number of concurrent offers to maintain
offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel)
ttl?: number // Time-to-live for offers in milliseconds (default: 300000)
}
export interface ConnectionContext {
pc: RTCPeerConnection
dc: RTCDataChannel
serviceFqn: string
offerId: string
peerUsername: string
}
export interface ConnectToServiceOptions {
serviceFqn?: string // Full FQN like 'chat:2.0.0@alice'
service?: string // Service without username (for discovery)
username?: string // Target username (combined with service)
onConnection?: (context: ConnectionContext) => void | Promise<void> // Called when data channel opens
rtcConfig?: RTCConfiguration // Optional: override default ICE servers
}
interface ActiveOffer {
offerId: string
serviceFqn: string
pc: RTCPeerConnection
dc?: RTCDataChannel
answered: boolean
createdAt: number
}
/**
* Rondevu - Complete WebRTC signaling client
*
* Provides a unified API for:
* - Username claiming with Ed25519 signatures
* - Implicit username claiming (auto-claimed on first authenticated request)
* - Service publishing with automatic signature generation
* - Service discovery (direct, random, paginated)
* - WebRTC signaling (offer/answer exchange, ICE relay)
@@ -25,130 +111,196 @@ export interface PublishServiceOptions {
*
* @example
* ```typescript
* // Initialize (generates keypair automatically)
* const rondevu = new Rondevu({
* // Create and initialize Rondevu instance with preset ICE servers
* const rondevu = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'alice',
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
* })
*
* await rondevu.initialize()
*
* // Claim username (one time)
* await rondevu.claimUsername()
*
* // Publish a service
* const publishedService = await rondevu.publishService({
* serviceFqn: 'chat:1.0.0@alice',
* offers: [{ sdp: offerSdp }],
* ttl: 300000,
* // Or use custom ICE servers
* const rondevu2 = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'bob',
* iceServers: [
* { urls: 'stun:stun.l.google.com:19302' },
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
* ]
* })
*
* // Discover a service
* const service = await rondevu.getService('chat:1.0.0@bob')
* // Publish a service with automatic offer management
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => {
* const pc = new RTCPeerConnection(rtcConfig)
* const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer()
* await pc.setLocalDescription(offer)
* return { pc, dc, offer }
* }
* })
*
* // Post answer
* await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
* // Start accepting connections (auto-fills offers and polls)
* await rondevu.startFilling()
*
* // Access active connections
* for (const offer of rondevu.getActiveOffers()) {
* offer.dc?.addEventListener('message', (e) => console.log(e.data))
* }
*
* // Stop when done
* rondevu.stopFilling()
* ```
*/
export class Rondevu {
private readonly api: RondevuAPI
private readonly username: string
private keypair: Keypair | null = null
// Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
private api: RondevuAPI
private readonly apiUrl: string
private username: string
private keypair: Keypair
private usernameClaimed = false
private cryptoAdapter?: CryptoAdapter
private batchingOptions?: BatcherOptions | false
private iceServers: RTCIceServer[]
private debugEnabled: boolean
constructor(options: RondevuOptions) {
this.username = options.username
this.keypair = options.keypair || null
this.api = new RondevuAPI(options.apiUrl, options.credentials)
// Service management
private currentService: string | null = null
private maxOffers = 0
private offerFactory: OfferFactory | null = null
private ttl = Rondevu.DEFAULT_TTL_MS
private activeOffers = new Map<string, ActiveOffer>()
console.log('[Rondevu] Constructor called:', {
// Polling
private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
private constructor(
apiUrl: string,
username: string,
keypair: Keypair,
api: RondevuAPI,
iceServers: RTCIceServer[],
cryptoAdapter?: CryptoAdapter,
batchingOptions?: BatcherOptions | false,
debugEnabled = false
) {
this.apiUrl = apiUrl
this.username = username
this.keypair = keypair
this.api = api
this.iceServers = iceServers
this.cryptoAdapter = cryptoAdapter
this.batchingOptions = batchingOptions
this.debugEnabled = debugEnabled
this.debug('Instance created:', {
username: this.username,
hasKeypair: !!this.keypair,
publicKey: this.keypair?.publicKey
publicKey: this.keypair.publicKey,
hasIceServers: iceServers.length > 0,
batchingEnabled: batchingOptions !== false
})
}
// ============================================
// Initialization
// ============================================
/**
* Internal debug logging - only logs if debug mode is enabled
*/
private debug(message: string, ...args: any[]): void {
if (this.debugEnabled) {
console.log(`[Rondevu] ${message}`, ...args)
}
}
/**
* Initialize the service - generates keypair if not provided
* Call this before using other methods
* Create and initialize a Rondevu client
*
* @example
* ```typescript
* const rondevu = await Rondevu.connect({
* apiUrl: 'https://api.ronde.vu',
* username: 'alice'
* })
* ```
*/
async initialize(): Promise<void> {
console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair)
static async connect(options: RondevuOptions): Promise<Rondevu> {
const username = options.username || Rondevu.generateAnonymousUsername()
if (!this.keypair) {
console.log('[Rondevu] Generating new keypair...')
this.keypair = await RondevuAPI.generateKeypair()
console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
// Handle preset string or custom array
let iceServers: RTCIceServer[]
if (typeof options.iceServers === 'string') {
iceServers = ICE_SERVER_PRESETS[options.iceServers]
} else {
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
iceServers = options.iceServers || [
{ urls: 'stun:stun.l.google.com:19302' }
]
}
// Register with API if no credentials provided
if (!this.api['credentials']) {
const credentials = await this.api.register()
this.api.setCredentials(credentials)
if (options.debug) {
console.log('[Rondevu] Connecting:', {
username,
hasKeypair: !!options.keypair,
iceServers: iceServers.length,
batchingEnabled: options.batching !== false
})
}
// Generate keypair if not provided
let keypair = options.keypair
if (!keypair) {
if (options.debug) console.log('[Rondevu] Generating new keypair...')
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter)
if (options.debug) console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey)
} else {
if (options.debug) console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey)
}
// Create API instance
const api = new RondevuAPI(
options.apiUrl,
username,
keypair,
options.cryptoAdapter,
options.batching
)
if (options.debug) console.log('[Rondevu] Created API instance')
return new Rondevu(
options.apiUrl,
username,
keypair,
api,
iceServers,
options.cryptoAdapter,
options.batching,
options.debug || false
)
}
/**
* Generate an anonymous username with timestamp and random component
*/
private static generateAnonymousUsername(): string {
const timestamp = Date.now().toString(36)
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
.map(b => b.toString(16).padStart(2, '0')).join('')
return `anon-${timestamp}-${random}`
}
// ============================================
// Username Management
// ============================================
/**
* Claim the username with Ed25519 signature
* Should be called once before publishing services
*/
async claimUsername(): Promise<void> {
if (!this.keypair) {
throw new Error('Not initialized. Call initialize() first.')
}
// Check if username is already claimed
const check = await this.api.checkUsername(this.username)
if (!check.available) {
// Verify it's claimed by us
if (check.publicKey === this.keypair.publicKey) {
this.usernameClaimed = true
return
}
throw new Error(`Username "${this.username}" is already claimed by another user`)
}
// Generate signature for username claim
const message = `claim:${this.username}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Claim the username
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
this.usernameClaimed = true
}
/**
* Check if username has been claimed (checks with server)
*/
async isUsernameClaimed(): Promise<boolean> {
if (!this.keypair) {
return false
}
try {
const check = await this.api.checkUsername(this.username)
// Debug logging
console.log('[Rondevu] Username check:', {
username: this.username,
available: check.available,
serverPublicKey: check.publicKey,
localPublicKey: this.keypair.publicKey,
match: check.publicKey === this.keypair.publicKey
})
// Username is claimed if it's not available and owned by our public key
const claimed = !check.available && check.publicKey === this.keypair.publicKey
const claimed = await this.api.isUsernameClaimed()
// Update internal flag to match server state
this.usernameClaimed = claimed
@@ -165,36 +317,414 @@ export class Rondevu {
// ============================================
/**
* Publish a service with automatic signature generation
* Default offer factory - creates a simple data channel connection
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('Not initialized. Call initialize() first.')
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default')
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
}
if (!this.usernameClaimed) {
throw new Error(
'Username not claimed. Call claimUsername() first or the server will reject the service.'
/**
* Publish a service with automatic offer management
* Call startFilling() to begin accepting connections
*
* @example
* ```typescript
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5
* })
* await rondevu.startFilling()
* ```
*/
async publishService(options: PublishServiceOptions): Promise<void> {
const { service, maxOffers, offerFactory, ttl } = options
this.currentService = service
this.maxOffers = maxOffers
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this)
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`)
this.usernameClaimed = true
}
/**
* Set up ICE candidate handler to send candidates to the server
*/
private setupIceCandidateHandler(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): void {
pc.onicecandidate = async (event) => {
if (event.candidate) {
try {
await this.api.addOfferIceCandidates(
serviceFqn,
offerId,
[event.candidate.toJSON()]
)
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
}
}
}
const { serviceFqn, offers, ttl } = options
// Generate signature for service publication
const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Create service request
const serviceRequest: ServiceRequest = {
serviceFqn, // Must include @username
offers,
signature,
message,
ttl,
/**
* Create a single offer and publish it to the server
*/
private async createOffer(): Promise<void> {
if (!this.currentService || !this.offerFactory) {
throw new Error('Service not published. Call publishService() first.')
}
const rtcConfig: RTCConfiguration = {
iceServers: this.iceServers
}
this.debug('Creating new offer...')
// Create the offer using the factory
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}`
// Publish to server
return await this.api.publishService(serviceRequest)
const result = await this.api.publishService({
serviceFqn,
offers: [{ sdp: offer.sdp! }],
ttl: this.ttl,
signature: '',
message: '',
})
const offerId = result.offers[0].offerId
// Store active offer
this.activeOffers.set(offerId, {
offerId,
serviceFqn,
pc,
dc,
answered: false,
createdAt: Date.now()
})
this.debug(`Offer created: ${offerId}`)
// Set up ICE candidate handler
this.setupIceCandidateHandler(pc, serviceFqn, offerId)
// Monitor connection state
pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.activeOffers.delete(offerId)
this.fillOffers() // Try to replace failed offer
}
}
}
/**
* Fill offers to reach maxOffers count
*/
private async fillOffers(): Promise<void> {
if (!this.filling || !this.currentService) return
const currentCount = this.activeOffers.size
const needed = this.maxOffers - currentCount
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
for (let i = 0; i < needed; i++) {
try {
await this.createOffer()
} catch (err) {
console.error('[Rondevu] Failed to create offer:', err)
}
}
}
/**
* Poll for answers and ICE candidates (internal use for automatic offer management)
*/
private async pollInternal(): Promise<void> {
if (!this.filling) return
try {
const result = await this.api.poll(this.lastPollTimestamp)
// Process answers
for (const answer of result.answers) {
const activeOffer = this.activeOffers.get(answer.offerId)
if (activeOffer && !activeOffer.answered) {
this.debug(`Received answer for offer ${answer.offerId}`)
await activeOffer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
})
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
// Create replacement offer
this.fillOffers()
}
}
// Process ICE candidates
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
const activeOffer = this.activeOffers.get(offerId)
if (activeOffer) {
const answererCandidates = candidates.filter(c => c.role === 'answerer')
for (const item of answererCandidates) {
if (item.candidate) {
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
}
}
}
}
} catch (err) {
console.error('[Rondevu] Polling error:', err)
}
}
/**
* Start filling offers and polling for answers/ICE
* Call this after publishService() to begin accepting connections
*/
async startFilling(): Promise<void> {
if (this.filling) {
this.debug('Already filling')
return
}
if (!this.currentService) {
throw new Error('No service published. Call publishService() first.')
}
this.debug('Starting offer filling and polling')
this.filling = true
// Fill initial offers
await this.fillOffers()
// Start polling
this.pollingInterval = setInterval(() => {
this.pollInternal()
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Stop filling offers and polling
* Closes all active peer connections
*/
stopFilling(): void {
this.debug('Stopping offer filling and polling')
this.filling = false
// Stop polling
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
// Close all active connections
for (const [offerId, offer] of this.activeOffers.entries()) {
this.debug(`Closing offer ${offerId}`)
offer.dc?.close()
offer.pc.close()
}
this.activeOffers.clear()
}
/**
* Resolve the full service FQN from various input options
* Supports direct FQN, service+username, or service discovery
*/
private async resolveServiceFqn(options: ConnectToServiceOptions): Promise<string> {
const { serviceFqn, service, username } = options
if (serviceFqn) {
return serviceFqn
} else if (service && username) {
return `${service}@${username}`
} else if (service) {
// Discovery mode - get random service
this.debug(`Discovering service: ${service}`)
const discovered = await this.discoverService(service)
return discovered.serviceFqn
} else {
throw new Error('Either serviceFqn or service must be provided')
}
}
/**
* Start polling for remote ICE candidates
* Returns the polling interval ID
*/
private startIcePolling(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): ReturnType<typeof setInterval> {
let lastIceTimestamp = 0
return setInterval(async () => {
try {
const result = await this.api.getOfferIceCandidates(
serviceFqn,
offerId,
lastIceTimestamp
)
for (const item of result.candidates) {
if (item.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt
}
}
} catch (err) {
console.error('[Rondevu] Failed to poll ICE candidates:', err)
}
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Automatically connect to a service (answerer side)
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
*
* @example
* ```typescript
* // Connect to specific user
* const connection = await rondevu.connectToService({
* serviceFqn: 'chat:2.0.0@alice',
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* dc.addEventListener('message', (e) => console.log(e.data))
* dc.addEventListener('open', () => dc.send('Hello!'))
* }
* })
*
* // Discover random service
* const connection = await rondevu.connectToService({
* service: 'chat:2.0.0',
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* }
* })
* ```
*/
async connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext> {
const { onConnection, rtcConfig } = options
// Validate inputs
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
throw new Error('serviceFqn cannot be empty')
}
if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
throw new Error('service cannot be empty')
}
if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
throw new Error('username cannot be empty')
}
// Determine the full service FQN
const fqn = await this.resolveServiceFqn(options)
this.debug(`Connecting to service: ${fqn}`)
// 1. Get service offer
const serviceData = await this.api.getService(fqn)
this.debug(`Found service from @${serviceData.username}`)
// 2. Create RTCPeerConnection
const rtcConfiguration = rtcConfig || {
iceServers: this.iceServers
}
const pc = new RTCPeerConnection(rtcConfiguration)
// 3. Set up data channel handler (answerer receives it from offerer)
let dc: RTCDataChannel | null = null
const dataChannelPromise = new Promise<RTCDataChannel>((resolve) => {
pc.ondatachannel = (event) => {
this.debug('Data channel received from offerer')
dc = event.channel
resolve(dc)
}
})
// 4. Set up ICE candidate exchange
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId)
// 5. Poll for remote ICE candidates
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId)
// 6. Set remote description
await pc.setRemoteDescription({
type: 'offer',
sdp: serviceData.sdp
})
// 7. Create and send answer
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await this.api.answerOffer(
serviceData.serviceFqn,
serviceData.offerId,
answer.sdp!
)
// 8. Wait for data channel to be established
dc = await dataChannelPromise
// Create connection context
const context: ConnectionContext = {
pc,
dc,
serviceFqn: serviceData.serviceFqn,
offerId: serviceData.offerId,
peerUsername: serviceData.username
}
// 9. Set up connection state monitoring
pc.onconnectionstatechange = () => {
this.debug(`Connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
clearInterval(icePollInterval)
}
}
// 10. Wait for data channel to open and call onConnection
if (dc.readyState === 'open') {
this.debug('Data channel already open')
if (onConnection) {
await onConnection(context)
}
} else {
await new Promise<void>((resolve) => {
dc!.addEventListener('open', async () => {
this.debug('Data channel opened')
if (onConnection) {
await onConnection(context)
}
resolve()
})
})
}
return context
}
// ============================================
@@ -230,7 +760,7 @@ export class Rondevu {
createdAt: number
expiresAt: number
}> {
return await this.api.discoverService(serviceVersion)
return await this.api.getService(serviceVersion)
}
/**
@@ -251,7 +781,7 @@ export class Rondevu {
limit: number
offset: number
}> {
return await this.api.discoverServices(serviceVersion, limit, offset)
return await this.api.getService(serviceVersion, { limit, offset })
}
// ============================================
@@ -265,7 +795,8 @@ export class Rondevu {
success: boolean
offerId: string
}> {
return await this.api.postOfferAnswer(serviceFqn, offerId, sdp)
await this.api.answerOffer(serviceFqn, offerId, sdp)
return { success: true, offerId }
}
/**
@@ -280,6 +811,28 @@ export class Rondevu {
return await this.api.getOfferAnswer(serviceFqn, offerId)
}
/**
* Combined polling for answers and ICE candidates
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
*/
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
}>>
}> {
return await this.api.poll(since)
}
/**
* Add ICE candidates to specific offer
*/
@@ -307,7 +860,7 @@ export class Rondevu {
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
getKeypair(): Keypair {
return this.keypair
}
@@ -321,15 +874,15 @@ export class Rondevu {
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
getPublicKey(): string {
return this.keypair.publicKey
}
/**
* Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead
*/
getAPI(): RondevuAPI {
getAPIPublic(): RondevuAPI {
return this.api
}
}

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 = []
}
}

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))
}
}