19 Commits

Author SHA1 Message Date
a550641993 Release v0.9.1: Add detailed ICE candidate exchange logging 2025-12-07 11:13:32 +01:00
04603cfe2d Add detailed ICE candidate exchange logging
Added comprehensive logging to track WebRTC ICE candidate exchange:
- Log local candidate generation with type (host/srflx/relay)
- Log when candidates are sent to signaling server
- Log remote candidate reception and addition
- Log ICE gathering state changes
- Log ICE connection state changes
- Enhanced ICE error logging with details

This will help diagnose connection issues and TURN server problems.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 16:10:24 +01:00
20 changed files with 3866 additions and 641 deletions

547
MIGRATION.md Normal file
View File

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

991
README.md

File diff suppressed because it is too large Load Diff

14
package-lock.json generated
View File

@@ -1,20 +1,30 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.7.9",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.7.9",
"version": "0.9.1",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.5.1"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",

View File

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

View File

@@ -29,6 +29,8 @@ export class RondevuAuth {
/**
* Register a new peer and receive credentials
* Generates a cryptographically random peer ID (128-bit)
* @throws Error if registration fails
*/
async register(): Promise<Credentials> {
const response = await this.fetchFn(`${this.baseUrl}/register`, {

View File

@@ -1,83 +0,0 @@
// Declare Buffer for Node.js compatibility
declare const Buffer: any;
/**
* Simple bloom filter implementation for peer ID exclusion
* Uses multiple hash functions for better distribution
*/
export class BloomFilter {
private bits: Uint8Array;
private size: number;
private numHashes: number;
constructor(size: number = 1024, numHashes: number = 3) {
this.size = size;
this.numHashes = numHashes;
this.bits = new Uint8Array(Math.ceil(size / 8));
}
/**
* Add a peer ID to the filter
*/
add(peerId: string): void {
for (let i = 0; i < this.numHashes; i++) {
const hash = this.hash(peerId, i);
const index = hash % this.size;
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
this.bits[byteIndex] |= 1 << bitIndex;
}
}
/**
* Test if peer ID might be in the filter
*/
test(peerId: string): boolean {
for (let i = 0; i < this.numHashes; i++) {
const hash = this.hash(peerId, i);
const index = hash % this.size;
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
if (!(this.bits[byteIndex] & (1 << bitIndex))) {
return false;
}
}
return true;
}
/**
* Get raw bits for transmission
*/
toBytes(): Uint8Array {
return this.bits;
}
/**
* Convert to base64 for URL parameters
*/
toBase64(): string {
// Convert Uint8Array to regular array then to string
const binaryString = String.fromCharCode(...Array.from(this.bits));
// Use btoa for browser, or Buffer for Node.js
if (typeof btoa !== 'undefined') {
return btoa(binaryString);
} else if (typeof Buffer !== 'undefined') {
return Buffer.from(this.bits).toString('base64');
} else {
// Fallback: manual base64 encoding
throw new Error('No base64 encoding available');
}
}
/**
* Simple hash function (FNV-1a variant)
*/
private hash(str: string, seed: number): number {
let hash = 2166136261 ^ seed;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
}

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,6 +1,6 @@
/**
* @xtr-dev/rondevu-client
* WebRTC peer signaling and discovery client with topic-based discovery
* WebRTC peer signaling and discovery client with durable connections
*/
// Export main client class
@@ -11,22 +11,26 @@ export type { RondevuOptions } from './rondevu.js';
export { RondevuAuth } from './auth.js';
export type { Credentials, FetchFunction } from './auth.js';
// Export offers API
export { RondevuOffers } from './offers.js';
export type {
CreateOfferRequest,
Offer,
IceCandidate,
TopicInfo
} from './offers.js';
// Export username API
export { RondevuUsername } from './usernames.js';
export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js';
// Export bloom filter
export { BloomFilter } from './bloom.js';
// Export durable connection APIs
export { DurableConnection } from './durable/connection.js';
export { DurableChannel } from './durable/channel.js';
export { DurableService } from './durable/service.js';
// Export peer manager
export { default as RondevuPeer } from './peer/index.js';
// Export durable connection types
export type {
PeerOptions,
PeerEvents,
PeerTimeouts
} from './peer/index.js';
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
QueuedMessage,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo
} from './durable/types.js';

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

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

View File

@@ -9,6 +9,7 @@ export interface CreateOfferRequest {
topics: string[];
ttl?: number;
secret?: string;
info?: string;
}
export interface Offer {
@@ -21,6 +22,7 @@ export interface Offer {
lastSeen: number;
secret?: string;
hasSecret?: boolean;
info?: string;
answererPeerId?: string;
answerSdp?: string;
answeredAt?: number;

View File

@@ -40,13 +40,22 @@ export class ExchangingIceState extends PeerState {
this.lastIceTimestamp
);
if (candidates.length > 0) {
console.log(`📥 Received ${candidates.length} remote ICE candidate(s)`);
}
for (const cand of candidates) {
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
const type = cand.candidate.candidate.includes('typ host') ? 'host' :
cand.candidate.candidate.includes('typ srflx') ? 'srflx' :
cand.candidate.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Adding remote ${type} ICE candidate:`, cand.candidate.candidate);
try {
await this.peer.pc.addIceCandidate(new this.peer.RTCIceCandidate(cand.candidate));
console.log(`✅ Added remote ${type} ICE candidate`);
this.lastIceTimestamp = cand.createdAt;
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
console.warn(`⚠️ Failed to add remote ${type} ICE candidate:`, err);
this.lastIceTimestamp = cand.createdAt;
}
} else {
@@ -54,7 +63,7 @@ export class ExchangingIceState extends PeerState {
}
}
} catch (err) {
console.error('Error polling for ICE candidates:', err);
console.error('Error polling for ICE candidates:', err);
if (err instanceof Error && err.message.includes('not found')) {
this.cleanup();
const { FailedState } = await import('./failed-state.js');

View File

@@ -66,6 +66,7 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
{ urls: 'stun:stun1.l.google.com:19302' }
]
},
existingPeerConnection?: RTCPeerConnection,
rtcPeerConnection?: typeof RTCPeerConnection,
rtcSessionDescription?: typeof RTCSessionDescription,
rtcIceCandidate?: typeof RTCIceCandidate
@@ -92,7 +93,8 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
throw new Error('RTCIceCandidate is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.pc = new this.RTCPeerConnection(rtcConfig);
// Use existing peer connection if provided, otherwise create new one
this.pc = existingPeerConnection || new this.RTCPeerConnection(rtcConfig);
this._state = new IdleState(this);
this.setupPeerConnection();
@@ -103,18 +105,23 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
*/
private setupPeerConnection(): void {
this.connectionStateChangeHandler = () => {
console.log(`🔌 Connection state changed: ${this.pc.connectionState}`);
switch (this.pc.connectionState) {
case 'connected':
console.log('✅ WebRTC connection established');
this.setState(new ConnectedState(this));
this.emitEvent('connected');
break;
case 'disconnected':
console.log('⚠️ WebRTC connection disconnected');
this.emitEvent('disconnected');
break;
case 'failed':
console.log('❌ WebRTC connection failed');
this.setState(new FailedState(this, new Error('Connection failed')));
break;
case 'closed':
console.log('🔒 WebRTC connection closed');
this.setState(new ClosedState(this));
this.emitEvent('disconnected');
break;
@@ -122,6 +129,18 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
};
this.pc.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
// Add ICE connection state logging
const iceConnectionStateHandler = () => {
console.log(`🧊 ICE connection state: ${this.pc.iceConnectionState}`);
};
this.pc.addEventListener('iceconnectionstatechange', iceConnectionStateHandler);
// Add ICE gathering state logging
const iceGatheringStateHandler = () => {
console.log(`🔍 ICE gathering state: ${this.pc.iceGatheringState}`);
};
this.pc.addEventListener('icegatheringstatechange', iceGatheringStateHandler);
this.dataChannelHandler = (event: RTCDataChannelEvent) => {
this.emitEvent('datachannel', event.channel);
};
@@ -133,7 +152,13 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
this.pc.addEventListener('track', this.trackHandler);
this.iceCandidateErrorHandler = (event: Event) => {
console.error('ICE candidate error:', event);
const iceError = event as RTCPeerConnectionIceErrorEvent;
console.error(`❌ ICE candidate error: ${iceError.errorText || 'Unknown error'}`, {
errorCode: iceError.errorCode,
url: iceError.url,
address: iceError.address,
port: iceError.port
});
};
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}

View File

@@ -40,12 +40,19 @@ export abstract class PeerState {
if (event.candidate && this.peer.offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Generated ${type} ICE candidate:`, candidateData.candidate);
try {
await this.peer.offersApi.addIceCandidates(this.peer.offerId, [candidateData]);
console.log(`✅ Sent ${type} ICE candidate to server`);
} catch (err) {
console.error('Error sending ICE candidate:', err);
console.error(`Error sending ${type} ICE candidate:`, err);
}
}
} else if (!event.candidate) {
console.log('🧊 ICE gathering complete (null candidate)');
}
};
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);

View File

@@ -1,6 +1,15 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js';
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { DurableService } from './durable/service.js';
import { DurableConnection } from './durable/connection.js';
import { DurableChannel } from './durable/channel.js';
import type {
DurableServiceConfig,
DurableConnectionConfig,
ConnectionInfo
} from './durable/types.js';
export interface RondevuOptions {
/**
@@ -65,6 +74,8 @@ export interface RondevuOptions {
export class Rondevu {
readonly auth: RondevuAuth;
readonly usernames: RondevuUsername;
private _offers?: RondevuOffers;
private credentials?: Credentials;
private baseUrl: string;
@@ -81,6 +92,7 @@ export class Rondevu {
this.rtcIceCandidate = options.RTCIceCandidate;
this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
this.usernames = new RondevuUsername(this.baseUrl);
if (options.credentials) {
this.credentials = options.credentials;
@@ -89,7 +101,8 @@ export class Rondevu {
}
/**
* Get offers API (requires authentication)
* Get offers API (low-level access, requires authentication)
* For most use cases, use the durable connection APIs instead
*/
get offers(): RondevuOffers {
if (!this._offers) {
@@ -100,6 +113,7 @@ export class Rondevu {
/**
* Register and initialize authenticated client
* Generates a cryptographically random peer ID (128-bit)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
@@ -143,9 +157,140 @@ export class Rondevu {
return new RondevuPeer(
this._offers,
rtcConfig,
undefined, // No existing peer connection
this.rtcPeerConnection,
this.rtcSessionDescription,
this.rtcIceCandidate
);
}
/**
* Expose a durable service with automatic reconnection and TTL refresh
*
* Creates a service that handles incoming connections with automatic
* reconnection and message queuing during network interruptions.
*
* @param config Service configuration
* @returns DurableService instance
*
* @example
* ```typescript
* const service = await client.exposeService({
* username: 'alice',
* privateKey: keypair.privateKey,
* serviceFqn: 'chat@1.0.0',
* poolSize: 10,
* handler: (channel, connectionId) => {
* channel.on('message', (data) => {
* console.log('Received:', data);
* channel.send(`Echo: ${data}`);
* });
* }
* });
*
* await service.start();
* ```
*/
async exposeService(
config: DurableServiceConfig & {
handler: (channel: DurableChannel, connectionId: string) => void | Promise<void>;
}
): Promise<DurableService> {
if (!this._offers || !this.credentials) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
const service = new DurableService(
this._offers,
this.baseUrl,
this.credentials,
config.handler,
config
);
return service;
}
/**
* Create a durable connection to a service by username and service FQN
*
* Establishes a WebRTC connection with automatic reconnection and
* message queuing during network interruptions.
*
* @param username Username of the service provider
* @param serviceFqn Fully qualified service name
* @param config Optional connection configuration
* @returns DurableConnection instance
*
* @example
* ```typescript
* const connection = await client.connect('alice', 'chat@1.0.0', {
* maxReconnectAttempts: 5
* });
*
* const channel = connection.createChannel('main');
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* await connection.connect();
* channel.send('Hello!');
* ```
*/
async connect(
username: string,
serviceFqn: string,
config?: DurableConnectionConfig
): Promise<DurableConnection> {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
const connectionInfo: ConnectionInfo = {
username,
serviceFqn
};
return new DurableConnection(this._offers, connectionInfo, config);
}
/**
* Create a durable connection to a service by UUID
*
* Establishes a WebRTC connection with automatic reconnection and
* message queuing during network interruptions.
*
* @param uuid Service UUID
* @param config Optional connection configuration
* @returns DurableConnection instance
*
* @example
* ```typescript
* const connection = await client.connectByUuid('service-uuid-here', {
* maxReconnectAttempts: 5
* });
*
* const channel = connection.createChannel('main');
* channel.on('message', (data) => {
* console.log('Received:', data);
* });
*
* await connection.connect();
* channel.send('Hello!');
* ```
*/
async connectByUuid(
uuid: string,
config?: DurableConnectionConfig
): Promise<DurableConnection> {
if (!this._offers) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
const connectionInfo: ConnectionInfo = {
uuid
};
return new DurableConnection(this._offers, connectionInfo, config);
}
}

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

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

200
src/usernames.ts Normal file
View File

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