109 Commits

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

Keep inline Node.js example code for reference.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All types and classes exported from main index.

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

const bus = new EventBus<MyEvents>();

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

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

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

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

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

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

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

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

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

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

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

This will help diagnose connection issues and TURN server problems.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:26:23 +01:00
895e7765f9 Update README: Remove custom peer ID documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:19:07 +01:00
6057c3c582 0.7.11 2025-11-22 17:34:11 +01:00
255fe42a43 Add optional info field to offers
- Add info field to CreateOfferRequest and Offer types
- Update README with info field examples and documentation
- Public metadata field visible in all API responses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:34:11 +01:00
83fd0f84a4 0.7.10 2025-11-22 16:10:28 +01:00
aa53d5bc3d Add custom peer ID support to register method
- Update register() to accept optional customPeerId parameter
- Add TypeScript documentation with JSDoc comments
- Update README with usage examples and documentation
- Maintain backward compatibility with auto-generated IDs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 16:10:24 +01:00
f5aa6e2189 0.7.9 2025-11-17 22:32:09 +01:00
afdca83640 Add createDataChannel method to RondevuPeer
Adds a public method to create RTCDataChannels for sending/receiving arbitrary data between peers. The offerer can call this method before creating an offer, and the answerer will receive the channel via the existing 'datachannel' event.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 22:31:58 +01:00
c7ea1b9b8f 0.7.8 2025-11-17 22:08:54 +01:00
660663945e Update README to remove scoped package name from title 2025-11-17 21:45:03 +01:00
f119a42fcd Update README to include live API link for rondevu-server 2025-11-17 21:44:13 +01:00
cd55072acb Update live demo link in README to use ronde.vu domain 2025-11-17 21:43:09 +01:00
26f71e7a2b Expand README with links to related repositories and NPM packages 2025-11-17 21:41:45 +01:00
0ac1f94502 Integrate secret parameter into peer classes
- Add secret field to PeerOptions interface
- Pass secret when creating offers in CreatingOfferState
- Pass secret when answering offers in AnsweringState
- Bump version to 0.7.7

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:13:19 +01:00
3530213870 Update README with secret field documentation
- Document secret parameter in offer creation examples
- Add Protected Offers section with detailed usage
- Update API reference for create() and answer() methods
- Show hasSecret flag in discovery responses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:03:49 +01:00
e052464482 Add startsWith parameter to getTopics method
Added optional startsWith parameter to topics query:
- Filters topics by prefix on the server side
- Updated TypeScript types
- Supports response with startsWith field

Version bumped to 0.7.5

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:42:00 +01:00
53206d306b Add WebRTC polyfill support for Node.js environments
Added optional polyfill parameters to RondevuOptions to support Node.js:
- RTCPeerConnection: Custom peer connection implementation
- RTCSessionDescription: Custom session description implementation
- RTCIceCandidate: Custom ICE candidate implementation

This allows users to plug in wrtc or node-webrtc packages for full
WebRTC support in Node.js environments. Updated documentation with
usage examples and environment compatibility matrix.

Version bumped to 0.7.4

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:16:42 +01:00
c860419e66 Remove unused code (legacy files and heartbeat method)
- Removed unused legacy files: client.ts and types.ts (old API)
- Removed heartbeat() method from offers API (doesn't actually reset TTL)
- Removed heartbeat() documentation from README
- Server only uses expires_at for cleanup, last_seen is never checked
- Offers expire purely based on their original TTL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:32:21 +01:00
e22e74fb74 Update README to use client.createPeer() method
- Replaced `new RondevuPeer(client.offers)` with `client.createPeer()`
- Updated import to only import Rondevu (not RondevuPeer)
- Updated Custom RTCConfiguration example to pass config to createPeer()
- Removed rtcConfig from answer() call (should be passed to createPeer)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:03:39 +01:00
135eda73cf Update README to reflect current RondevuPeer API
- Replaced all references to removed RondevuConnection class
- Updated to use RondevuPeer with state machine lifecycle
- Documented state transitions (idle → creating-offer → waiting-for-answer → exchanging-ice → connected)
- Added trickle ICE documentation
- Updated all code examples to use addEventListener
- Added timeout configuration examples
- Documented peer properties (stateName, connectionState, offerId, role)
- Updated TypeScript types in API reference

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:00:15 +01:00
8d7075ccc4 0.7.3 2025-11-16 17:51:24 +01:00
db8f0f4ced Fix answerer authorization for ICE candidates
The answerer was getting 403 Forbidden when sending ICE candidates because
the server didn't know who the answerer was yet. ICE gathering starts when
setLocalDescription is called, but we were calling /answer AFTER that.

Fixed by sending the answer to the server BEFORE setLocalDescription:
1. Create answer SDP
2. Send answer to server (registers answererPeerId)
3. Set up ICE handler
4. Set local description (ICE gathering starts)

This ensures the server has answererPeerId set before ICE candidates arrive,
so they're properly authorized.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:51:04 +01:00
3a227a21ac 0.7.2 2025-11-16 17:45:02 +01:00
de1f3eac9c Fix critical ICE candidate timing bug
ICE candidate handler was being set up AFTER setLocalDescription, but ICE
gathering starts when setLocalDescription is called. This meant candidates
were generated before the handler was attached, so they were never sent to
the server, causing connection failures.

Fixed by:
- Setting up ICE handler BEFORE setLocalDescription in both offer and answer flows
- Changed setupIceCandidateHandler() to use this.peer.offerId instead of parameter
- Handler now checks this.peer.offerId before sending (waits for it to be set)

Order of operations now:
1. Set up ICE candidate handler
2. Call setLocalDescription (ICE gathering starts)
3. Set this.peer.offerId (handler can now send candidates)

This ensures all ICE candidates are captured and sent to the server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:44:55 +01:00
557cc0a838 0.7.1 2025-11-16 17:35:47 +01:00
6e661f69bc Extract duplicate ICE candidate handler code to base PeerState class
Refactored common ICE candidate handling logic to reduce code duplication:
- Added setupIceCandidateHandler() method to base PeerState class
- Moved iceCandidateHandler property to base class
- Updated cleanup() in base class to remove ICE candidate handler
- Removed duplicate handler code from CreatingOfferState and AnsweringState
- Both states now call this.setupIceCandidateHandler(offerId)

This eliminates ~15 lines of duplicated code per state and ensures consistent ICE candidate handling across all states that need it.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:35:40 +01:00
00f4da7250 Replace .on* event handlers with addEventListener/removeEventListener
Updated all event handler assignments to use addEventListener instead of .on* properties:
- peer/index.ts: Replaced onconnectionstatechange, ondatachannel, ontrack, onicecandidateerror
- creating-offer-state.ts: Replaced onicecandidate
- answering-state.ts: Replaced onicecandidate

Benefits:
- Proper cleanup with removeEventListener
- Prevents memory leaks by removing listeners when states/peer close
- Allows multiple listeners for the same event
- More modern and explicit event handling approach

All event handlers are now stored as class properties and properly cleaned up in cleanup()/close() methods.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:33:32 +01:00
6c344ec8e1 0.7.0 2025-11-16 17:28:25 +01:00
5a5da124a6 Refactor peer connection state machine into separate files
Split the monolithic peer.ts file into a modular state-based architecture:
- Created separate files for each state class (idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed)
- Extracted shared types into types.ts
- Extracted base PeerState class into state.ts
- Updated peer/index.ts to import state classes instead of defining them inline
- Made close() method async to support dynamic imports and avoid circular dependencies
- Used dynamic imports in state transitions to prevent circular dependency issues

This improves code organization, maintainability, and makes each state's logic easier to understand and test.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:28:12 +01:00
c8b7a2913f feat: Implement proper trickle ICE support
Major improvements to connection establishment:

**Trickle ICE Implementation:**
- Send offer/answer to server IMMEDIATELY after creating SDP
- Don't wait for ICE gathering before sending offer/answer
- ICE candidates are now sent as they're discovered (true trickle ICE)
- Connection attempts can start with first candidates while more gather

**Removed Delays:**
- CreatingOfferState: No longer waits 10-15s for ICE before sending offer
- AnsweringState: No longer waits 10-15s for ICE before sending answer
- Answering state now takes ~50-200ms instead of 15+ seconds

**Code Organization:**
- Moved peer.ts to peer/index.ts directory structure
- Removed unused pendingCandidates buffering
- Removed unused waitForIceGathering methods
- Cleaned up timeout handling

**Breaking Changes:**
- "answering" state now transitions much faster to "exchanging-ice"
- ICE candidates start trickling immediately instead of in batches

This dramatically improves connection speed and follows WebRTC best practices.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:12:18 +01:00
6ddf7cb7f0 fix: Clear answer creation timeout before ICE gathering
The timeout for creating an answer was incorrectly including the
ICE gathering process, causing the answerer to fail when ICE gathering
took close to the timeout duration.

Now the timeout is cleared immediately after createAnswer() completes,
and ICE gathering relies on its own separate timeout.

Fixes connection failures where answerer would timeout even though
the answer was created successfully.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:47:58 +01:00
35ce051a26 chore: Bump version to 0.5.0
Breaking changes:
- Removed RondevuConnection (replaced by RondevuPeer)
- EventEmitter now uses protected emit()
- Content-based offer IDs (SHA-256 hash)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:40:25 +01:00
280c8c284f feat: Replace RondevuConnection with RondevuPeer state machine
- Created type-safe EventEmitter with generics
- Implemented state pattern for peer connection lifecycle
- Added comprehensive timeout handling for all connection phases
- Removed client-provided offer IDs (server generates hash-based IDs)
- Replaced RondevuConnection with RondevuPeer throughout
- Added states: idle, creating-offer, waiting-for-answer, answering, exchanging-ice, connected, failed, closed
- Configurable timeouts: ICE gathering, waiting for answer, creating answer, ICE connection
- Better error handling with 'failed' event and error details

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:33:44 +01:00
14d3f943da 0.4.1 2025-11-14 20:46:47 +01:00
2989326a50 Fix: Initialize lastIceTimestamp to 0 to get all candidates
Critical bug fix: lastIceTimestamp was initialized to Date.now(),
causing the first poll to miss early ICE candidates that were sent
before polling started. This resulted in ICE failure.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes: RTCPeerConnection not defined error in Node.js

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 21:51:11 +01:00
18 changed files with 5980 additions and 1224 deletions

9
.prettierrc.json Normal file
View File

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

486
ADVANCED.md Normal file
View File

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

547
MIGRATION.md Normal file
View File

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

215
README.md
View File

@@ -1,82 +1,177 @@
# Rondevu # Rondevu Client
🎯 **Simple WebRTC peer signaling and discovery**
Meet peers by topic, by peer ID, or by connection ID.
**Related repositories:**
- [rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server
- [rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo
---
## @xtr-dev/rondevu-client
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
TypeScript client library for Rondevu peer signaling and WebRTC connection management. Handles automatic signaling, ICE candidate exchange, and connection establishment. 🌐 **Simple WebRTC signaling client with username-based discovery**
### Install 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))
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
---
## Features
- **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
- **Keypair Management**: Generate or reuse Ed25519 keypairs
- **Automatic Signatures**: All authenticated requests signed automatically
## Installation
```bash ```bash
npm install @xtr-dev/rondevu-client npm install @xtr-dev/rondevu-client
``` ```
### Usage ## Quick Start
### Publishing a Service (Offerer)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { Rondevu } from '@xtr-dev/rondevu-client'
const rdv = new Rondevu({ // 1. Connect to Rondevu
baseUrl: 'https://server.com', const rondevu = await Rondevu.connect({
rtcConfig: { apiUrl: 'https://api.ronde.vu',
iceServers: [ username: 'alice', // Or omit for anonymous username
// your ICE servers here iceServers: 'ipv4-turn' // Preset: 'ipv4-turn', 'hostname-turns', 'google-stun', 'relay-only'
{ urls: 'stun:stun.l.google.com:19302' }, })
{ urls: 'stun:stun1.l.google.com:19302' },
{ // 2. Publish service with automatic offer management
urls: 'turn:relay1.example.com:3480', await rondevu.publishService({
username: 'example', service: 'chat:1.0.0',
credential: 'example' maxOffers: 5, // Maintain up to 5 concurrent offers
} offerFactory: async (rtcConfig) => {
] const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => {
console.log('Connection opened!')
dc.send('Hello from Alice!')
})
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
} }
}); })
// Connect by topic // 3. Start accepting connections
const conn = await rdv.join('room'); await rondevu.startFilling()
// Or connect by ID
const conn = await rdv.connect('meeting-123');
// Use the connection
conn.on('connect', () => {
const channel = conn.dataChannel('chat');
channel.send('Hello!');
});
``` ```
### API ### Connecting to a Service (Answerer)
**Main Methods:** ```typescript
- `rdv.join(topic)` - Auto-connect to first peer in topic import { Rondevu } from '@xtr-dev/rondevu-client'
- `rdv.join(topic, {filter})` - Connect to specific peer by ID
- `rdv.create(id, topic)` - Create connection for others to join
- `rdv.connect(id)` - Join connection by ID
**Connection Events:** // 1. Connect to Rondevu
- `connect` - Connection established const rondevu = await Rondevu.connect({
- `disconnect` - Connection closed apiUrl: 'https://api.ronde.vu',
- `datachannel` - Remote peer created data channel username: 'bob',
- `stream` - Remote media stream received iceServers: 'ipv4-turn'
- `error` - Error occurred })
**Connection Methods:** // 2. Connect to service (automatic WebRTC setup)
- `conn.dataChannel(label)` - Get or create data channel const connection = await rondevu.connectToService({
- `conn.addStream(stream)` - Add media stream serviceFqn: 'chat:1.0.0@alice',
- `conn.getPeerConnection()` - Get underlying RTCPeerConnection onConnection: ({ dc, peerUsername }) => {
- `conn.close()` - Close connection console.log('Connected to', peerUsername)
### License dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
dc.addEventListener('open', () => {
dc.send('Hello from Bob!')
})
}
})
// Access connection
connection.dc.send('Another message')
connection.pc.close() // Close when done
```
## Core API
### 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
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu))
## License
MIT MIT

52
eslint.config.js Normal file
View File

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

2997
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

423
src/api.ts Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,42 +1,39 @@
/** /**
* @xtr-dev/rondevu-client * @xtr-dev/rondevu-client
* WebRTC peer signaling and discovery client * WebRTC peer signaling client
*/ */
// Export main WebRTC client class export { Rondevu } from './rondevu.js'
export { Rondevu } from './rondevu'; export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js'
// Export connection class // Export crypto adapters
export { RondevuConnection } from './connection'; export { WebCryptoAdapter } from './web-crypto-adapter.js'
export { NodeCryptoAdapter } from './node-crypto-adapter.js'
// Export low-level signaling client (for advanced usage) // Export types
export { RondevuClient } from './client';
// Export all types
export type { export type {
// WebRTC types Signaler,
RondevuOptions, Binnable,
JoinOptions, } from './types.js'
ConnectionRole,
RondevuConnectionParams, export type {
RondevuConnectionEvents, Keypair,
// Signaling types OfferRequest,
Side, ServiceRequest,
Session, Service,
TopicInfo, ServiceOffer,
Pagination, IceCandidate,
ListTopicsResponse, } from './api.js'
ListSessionsResponse,
CreateOfferRequest, export type {
CreateOfferResponse, RondevuOptions,
AnswerRequest, PublishServiceOptions,
AnswerResponse, ConnectToServiceOptions,
PollRequest, ConnectionContext,
PollOffererResponse, OfferContext,
PollAnswererResponse, OfferFactory
PollResponse, } from './rondevu.js'
VersionResponse,
HealthResponse, export type { CryptoAdapter } from './crypto-adapter.js'
ErrorResponse,
RondevuClientOptions,
} from './types';

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

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