31 Commits

Author SHA1 Message Date
b2d42fa776 fix: use async ed25519 functions (signAsync, getPublicKeyAsync)
The sync ed25519 functions (sign, getPublicKey) require hashes.sha512,
but WebCrypto only provides async digest. Switch to using the async
ed25519 API which works with hashes.sha512Async.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Version bumped to 0.7.5

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

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

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

Version bumped to 0.7.4

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:33:32 +01:00
21 changed files with 2247 additions and 850 deletions

808
README.md
View File

@@ -1,24 +1,26 @@
# @xtr-dev/rondevu-client
# Rondevu Client
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
🌐 **Topic-based peer discovery and WebRTC signaling client**
🌐 **DNS-like WebRTC client with username claiming and service discovery**
TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling.
TypeScript/JavaScript client for Rondevu, providing cryptographic username claiming, service publishing, and privacy-preserving discovery.
**Related repositories:**
- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server
- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
---
## Features
- **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes)
- **Stateless Authentication**: No server-side sessions, portable credentials
- **Bloom Filters**: Efficient peer exclusion for repeated discoveries
- **Multi-Offer Management**: Create and manage multiple offers per peer
- **Username Claiming**: Cryptographic ownership with Ed25519 signatures
- **Service Publishing**: Package-style naming (com.example.chat@1.0.0)
- **Privacy-Preserving Discovery**: UUID-based service index
- **Public/Private Services**: Control service visibility
- **Complete WebRTC Signaling**: Full offer/answer and ICE candidate exchange
- **Trickle ICE**: Send ICE candidates as they're discovered
- **TypeScript**: Full type safety and autocomplete
## Install
@@ -29,176 +31,484 @@ npm install @xtr-dev/rondevu-client
## Quick Start
The easiest way to use Rondevu is with the high-level `RondevuConnection` class, which handles all WebRTC connection complexity including offer/answer exchange, ICE candidates, and connection lifecycle.
### Creating an Offer (Peer A)
### Publishing a Service (Alice)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Create a connection
const conn = client.createConnection();
// Step 1: Claim username (one-time)
const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// Set up event listeners
conn.on('connected', () => {
console.log('Connected to peer!');
});
console.log(`Username claimed: ${claim.username}`);
console.log(`Expires: ${new Date(claim.expiresAt)}`);
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
// Step 2: Expose service with handler
const keypair = client.usernames.loadKeypairFromStorage('alice');
channel.onmessage = (event) => {
console.log('Received:', event.data);
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: true,
handler: (channel, peer) => {
console.log('📡 New connection established');
channel.onmessage = (e) => {
console.log('📥 Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
channel.send('Hello from peer A!');
channel.onopen = () => {
console.log('✅ Data channel open');
};
}
});
// Create offer and advertise on topics
const offerId = await conn.createOffer({
topics: ['my-app', 'room-123'],
ttl: 300000 // 5 minutes
});
console.log(`Service published with UUID: ${handle.uuid}`);
console.log('Waiting for connections...');
console.log('Offer created:', offerId);
console.log('Share these topics with peers:', ['my-app', 'room-123']);
// Later: unpublish
await handle.unpublish();
```
### Answering an Offer (Peer B)
### Connecting to a Service (Bob)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Discover offers by topic
const offers = await client.offers.findByTopic('my-app', { limit: 10 });
// Option 1: Connect by username + FQN
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.chat@1.0.0'
);
if (offers.length > 0) {
const offer = offers[0];
channel.onmessage = (e) => {
console.log('📥 Received:', e.data);
};
// Create connection
const conn = client.createConnection();
channel.onopen = () => {
console.log('✅ Connected!');
channel.send('Hello Alice!');
};
// Set up event listeners
conn.on('connecting', () => {
console.log('Connecting...');
});
peer.on('connected', () => {
console.log('🎉 WebRTC connection established');
});
conn.on('connected', () => {
console.log('Connected!');
});
peer.on('failed', (error) => {
console.error('Connection failed:', error);
});
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
// Option 2: List services first, then connect
const services = await client.discovery.listServices('alice');
console.log(`Found ${services.services.length} services`);
channel.onmessage = (event) => {
console.log('Received:', event.data);
for (const service of services.services) {
console.log(`- UUID: ${service.uuid}`);
if (service.isPublic) {
console.log(` FQN: ${service.serviceFqn}`);
}
}
// Connect by UUID
const { peer: peer2, channel: channel2 } = await client.discovery.connectByUuid(
services.services[0].uuid
);
```
## API Reference
### Main Client
```typescript
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu', // optional, default shown
credentials?: { peerId, secret }, // optional, skip registration
fetch?: customFetch, // optional, for Node.js < 18
RTCPeerConnection?: RTCPeerConnection, // optional, for Node.js
RTCSessionDescription?: RTCSessionDescription,
RTCIceCandidate?: RTCIceCandidate
});
// Register and get credentials
const creds = await client.register();
// { peerId: '...', secret: '...' }
// Check if authenticated
client.isAuthenticated(); // boolean
// Get current credentials
client.getCredentials(); // { peerId, secret } | undefined
```
### Username API
```typescript
// Check username availability
const check = await client.usernames.checkUsername('alice');
// { available: true } or { available: false, expiresAt: number, publicKey: string }
// Claim username with new keypair
const claim = await client.usernames.claimUsername('alice');
// { username, publicKey, privateKey, claimedAt, expiresAt }
// Claim with existing keypair
const keypair = await client.usernames.generateKeypair();
const claim2 = await client.usernames.claimUsername('bob', keypair);
// Save keypair to localStorage
client.usernames.saveKeypairToStorage('alice', publicKey, privateKey);
// Load keypair from localStorage
const stored = client.usernames.loadKeypairFromStorage('alice');
// { publicKey, privateKey } | null
// Export keypair for backup
const exported = client.usernames.exportKeypair('alice');
// { username, publicKey, privateKey }
// Import keypair from backup
client.usernames.importKeypair({ username: 'alice', publicKey, privateKey });
// Low-level: Generate keypair
const { publicKey, privateKey } = await client.usernames.generateKeypair();
// Low-level: Sign message
const signature = await client.usernames.signMessage(
'claim:alice:1234567890',
privateKey
);
// Low-level: Verify signature
const valid = await client.usernames.verifySignature(
'claim:alice:1234567890',
signature,
publicKey
);
```
**Username Rules:**
- Format: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`)
- Length: 3-32 characters
- Pattern: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`
- Validity: 365 days from claim/last use
- Ownership: Secured by Ed25519 public key
### Services API
```typescript
// Publish service (returns UUID)
const service = await client.services.publishService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: false, // optional, default false
metadata: { description: '...' }, // optional
ttl: 5 * 60 * 1000, // optional, default 5 minutes
rtcConfig: { ... } // optional RTCConfiguration
});
// { serviceId, uuid, offerId, expiresAt }
console.log(`Service UUID: ${service.uuid}`);
console.log('Share this UUID to allow connections');
// Expose service with automatic connection handling
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.echo@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
}
});
// Later: unpublish
await handle.unpublish();
// Unpublish service manually
await client.services.unpublishService(serviceId, username);
```
#### Multi-Connection Service Hosting (Offer Pooling)
By default, `exposeService()` creates a single offer and can only accept one connection. To handle multiple concurrent connections, use the `poolSize` option to enable **offer pooling**:
```typescript
// Expose service with offer pooling for multiple concurrent connections
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: true,
poolSize: 5, // Maintain 5 simultaneous open offers
pollingInterval: 2000, // Optional: polling interval in ms (default: 2000)
handler: (channel, peer, connectionId) => {
console.log(`📡 New connection: ${connectionId}`);
channel.onmessage = (e) => {
console.log(`📥 [${connectionId}] Received:`, e.data);
channel.send(`Echo: ${e.data}`);
};
channel.send('Hello from peer B!');
channel.onclose = () => {
console.log(`👋 [${connectionId}] Connection closed`);
};
},
onPoolStatus: (status) => {
console.log('Pool status:', {
activeOffers: status.activeOffers,
activeConnections: status.activeConnections,
totalHandled: status.totalConnectionsHandled
});
},
onError: (error, context) => {
console.error(`Pool error (${context}):`, error);
}
});
// Answer the offer
await conn.answer(offer.id, offer.sdp);
// Get current pool status
const status = handle.getStatus();
console.log(`Active offers: ${status.activeOffers}`);
console.log(`Active connections: ${status.activeConnections}`);
// Manually add more offers if needed
await handle.addOffers(3);
```
**How Offer Pooling Works:**
1. The pool maintains `poolSize` simultaneous open offers at all times
2. When an offer is answered (connection established), a new offer is automatically created
3. Polling checks for answers every `pollingInterval` milliseconds (default: 2000ms)
4. Each connection gets a unique `connectionId` passed to the handler
5. No limit on total concurrent connections - only pool size (open offers) is controlled
**Use Cases:**
- Chat servers handling multiple clients
- File sharing services with concurrent downloads
- Multiplayer game lobbies
- Collaborative editing sessions
- Any service that needs to accept multiple simultaneous connections
**Pool Status Interface:**
```typescript
interface PoolStatus {
activeOffers: number; // Current number of open offers
activeConnections: number; // Current number of connected peers
totalConnectionsHandled: number; // Total connections since start
failedOfferCreations: number; // Failed offer creation attempts
}
```
### Connection Events
**Pooled Service Handle:**
```typescript
interface PooledServiceHandle extends ServiceHandle {
getStatus: () => PoolStatus; // Get current pool status
addOffers: (count: number) => Promise<void>; // Manually add offers
}
```
**Service FQN Format:**
- Service name: Reverse domain notation (e.g., `com.example.chat`)
- Version: Semantic versioning (e.g., `1.0.0`, `2.1.3-beta`)
- Complete FQN: `service-name@version`
- Examples: `com.example.chat@1.0.0`, `io.github.alice.notes@0.1.0-beta`
**Validation Rules:**
- Service name pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`
- Length: 3-128 characters
- Minimum 2 components (at least one dot)
- Version pattern: `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$`
### Discovery API
```typescript
conn.on('connecting', () => {
// Connection is being established
// List all services for a username
const services = await client.discovery.listServices('alice');
// {
// username: 'alice',
// services: [
// { uuid: 'abc123', isPublic: false },
// { uuid: 'def456', isPublic: true, serviceFqn: '...', metadata: {...} }
// ]
// }
// Query service by FQN
const query = await client.discovery.queryService('alice', 'com.example.chat@1.0.0');
// { uuid: 'abc123', allowed: true }
// Get service details by UUID
const details = await client.discovery.getServiceDetails('abc123');
// { serviceId, username, serviceFqn, offerId, sdp, isPublic, metadata, ... }
// Connect to service by UUID
const peer = await client.discovery.connectToService('abc123', {
rtcConfig: { ... }, // optional
onConnected: () => { ... }, // optional
onData: (data) => { ... } // optional
});
conn.on('connected', () => {
// Connection established successfully
// Connect by username + FQN (convenience method)
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.chat@1.0.0',
{ rtcConfig: { ... } } // optional
);
// Connect by UUID with channel
const { peer, channel } = await client.discovery.connectByUuid('abc123');
```
### Low-Level Peer Connection
```typescript
// Create peer connection
const peer = client.createPeer({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
],
iceTransportPolicy: 'relay' // optional: force TURN relay
});
conn.on('disconnected', () => {
// Connection lost or closed
// Event listeners
peer.on('state', (state) => {
console.log('Peer state:', state);
});
conn.on('error', (error) => {
// An error occurred
console.error('Connection error:', error);
peer.on('connected', () => {
console.log('✅ Connected');
});
conn.on('datachannel', (channel) => {
// Data channel is ready to use
peer.on('disconnected', () => {
console.log('🔌 Disconnected');
});
conn.on('track', (event) => {
// Media track received (for audio/video streaming)
peer.on('failed', (error) => {
console.error('❌ Failed:', error);
});
peer.on('datachannel', (channel) => {
console.log('📡 Data channel ready');
});
peer.on('track', (event) => {
// Media track received
const stream = event.streams[0];
videoElement.srcObject = stream;
});
```
### Adding Media Tracks
```typescript
// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
// Create offer
const offerId = await peer.createOffer({
ttl: 300000, // optional
timeouts: { // optional
iceGathering: 10000,
waitingForAnswer: 30000,
creatingAnswer: 10000,
iceConnection: 30000
}
});
// Add tracks to connection
// Answer offer
await peer.answer(offerId, sdp);
// Add media tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => {
conn.addTrack(track, stream);
peer.addTrack(track, stream);
});
// Close connection
await peer.close();
// Properties
peer.stateName; // 'idle', 'creating-offer', 'connected', etc.
peer.connectionState; // RTCPeerConnectionState
peer.offerId; // string | undefined
peer.role; // 'offerer' | 'answerer' | undefined
```
### Connection Properties
## Connection Lifecycle
```typescript
// Get connection state
console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc.
### Service Publisher (Offerer)
1. **idle** - Initial state
2. **creating-offer** - Creating WebRTC offer
3. **waiting-for-answer** - Polling for answer from peer
4. **exchanging-ice** - Exchanging ICE candidates
5. **connected** - Successfully connected
6. **failed** - Connection failed
7. **closed** - Connection closed
// Get offer ID
console.log(conn.id);
// Get data channel
console.log(conn.channel);
```
### Closing a Connection
```typescript
conn.close();
```
### Service Consumer (Answerer)
1. **idle** - Initial state
2. **answering** - Creating WebRTC answer
3. **exchanging-ice** - Exchanging ICE candidates
4. **connected** - Successfully connected
5. **failed** - Connection failed
6. **closed** - Connection closed
## Platform-Specific Setup
### Node.js 18+ (with native fetch)
### Modern Browsers
Works out of the box - no additional setup needed.
### Node.js < 18 (without native fetch)
Install node-fetch and provide it to the client:
### Node.js 18+
Native fetch is available, but you need WebRTC polyfills:
```bash
npm install node-fetch
npm install wrtc
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
```
### Node.js < 18
Install both fetch and WebRTC polyfills:
```bash
npm install node-fetch wrtc
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any
fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
```
### Deno
```typescript
import { Rondevu } from 'npm:@xtr-dev/rondevu-client';
@@ -208,11 +518,9 @@ const client = new Rondevu({
```
### Bun
Works out of the box - no additional setup needed.
### Cloudflare Workers
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
@@ -228,177 +536,114 @@ export default {
};
```
## Low-Level API Usage
## Examples
For advanced use cases where you need direct control over the signaling process, you can use the low-level API:
### Echo Service
```typescript
import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';
// Publisher
const client1 = new Rondevu();
await client1.register();
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
const claim = await client1.usernames.claimUsername('alice');
client1.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// Register and get credentials
const creds = await client.register();
console.log('Peer ID:', creds.peerId);
const keypair = client1.usernames.loadKeypairFromStorage('alice');
// Save credentials for later use
localStorage.setItem('rondevu-creds', JSON.stringify(creds));
// Create offer with topics
const offers = await client.offers.create([{
sdp: 'v=0...', // Your WebRTC offer SDP
topics: ['movie-xyz', 'hd-content'],
ttl: 300000 // 5 minutes
}]);
// Discover peers by topic
const discovered = await client.offers.findByTopic('movie-xyz', {
limit: 50
});
console.log(`Found ${discovered.length} peers`);
// Use bloom filter to exclude known peers
const knownPeers = new Set(['peer-id-1', 'peer-id-2']);
const bloom = new BloomFilter(1024, 3);
knownPeers.forEach(id => bloom.add(id));
const newPeers = await client.offers.findByTopic('movie-xyz', {
bloomFilter: bloom.toBytes(),
limit: 50
});
```
## API Reference
### Authentication
#### `client.register()`
Register a new peer and receive credentials.
```typescript
const creds = await client.register();
// { peerId: '...', secret: '...' }
```
### Topics
#### `client.offers.getTopics(options?)`
List all topics with active peer counts (paginated).
```typescript
const result = await client.offers.getTopics({
limit: 50,
offset: 0
});
// {
// topics: [
// { topic: 'movie-xyz', activePeers: 42 },
// { topic: 'torrent-abc', activePeers: 15 }
// ],
// total: 123,
// limit: 50,
// offset: 0
// }
```
### Offers
#### `client.offers.create(offers)`
Create one or more offers with topics.
```typescript
const offers = await client.offers.create([
{
sdp: 'v=0...',
topics: ['topic-1', 'topic-2'],
ttl: 300000 // optional, default 5 minutes
await client1.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.echo@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
}
]);
```
#### `client.offers.findByTopic(topic, options?)`
Find offers by topic with optional bloom filter.
```typescript
const offers = await client.offers.findByTopic('movie-xyz', {
limit: 50,
bloomFilter: bloomBytes // optional
});
// Consumer
const client2 = new Rondevu();
await client2.register();
const { peer, channel } = await client2.discovery.connect(
'alice',
'com.example.echo@1.0.0'
);
channel.onmessage = (e) => console.log('Received:', e.data);
channel.send('Hello!');
```
#### `client.offers.getMine()`
Get all offers owned by the authenticated peer.
### File Transfer Service
```typescript
const myOffers = await client.offers.getMine();
// Publisher
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.files@1.0.0',
isPublic: false,
handler: (channel, peer) => {
channel.binaryType = 'arraybuffer';
channel.onmessage = (e) => {
if (typeof e.data === 'string') {
console.log('Request:', JSON.parse(e.data));
} else {
console.log('Received file chunk:', e.data.byteLength, 'bytes');
}
};
}
});
// Consumer
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.files@1.0.0'
);
channel.binaryType = 'arraybuffer';
// Request file
channel.send(JSON.stringify({ action: 'get', path: '/readme.txt' }));
channel.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
console.log('Received file:', e.data.byteLength, 'bytes');
}
};
```
#### `client.offers.heartbeat(offerId)`
Update last_seen timestamp for an offer.
### Video Chat Service
```typescript
await client.offers.heartbeat(offerId);
```
// Publisher
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
#### `client.offers.delete(offerId)`
Delete a specific offer.
const peer = client.createPeer();
stream.getTracks().forEach(track => peer.addTrack(track, stream));
```typescript
await client.offers.delete(offerId);
```
const offerId = await peer.createOffer({ ttl: 300000 });
#### `client.offers.answer(offerId, sdp)`
Answer an offer (locks it to answerer).
await client.services.publishService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.videochat@1.0.0',
isPublic: true
});
```typescript
await client.offers.answer(offerId, answerSdp);
```
// Consumer
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.videochat@1.0.0'
);
#### `client.offers.getAnswers()`
Poll for answers to your offers.
```typescript
const answers = await client.offers.getAnswers();
```
### ICE Candidates
#### `client.offers.addIceCandidates(offerId, candidates)`
Post ICE candidates for an offer.
```typescript
await client.offers.addIceCandidates(offerId, [
'candidate:1 1 UDP...'
]);
```
#### `client.offers.getIceCandidates(offerId, since?)`
Get ICE candidates from the other peer.
```typescript
const candidates = await client.offers.getIceCandidates(offerId);
```
### Bloom Filter
```typescript
import { BloomFilter } from '@xtr-dev/rondevu-client';
// Create filter: size=1024 bits, hash=3 functions
const bloom = new BloomFilter(1024, 3);
// Add items
bloom.add('peer-id-1');
bloom.add('peer-id-2');
// Test membership
bloom.test('peer-id-1'); // true (probably)
bloom.test('unknown'); // false (definitely)
// Export for API
const bytes = bloom.toBytes();
peer.on('track', (event) => {
const remoteStream = event.streams[0];
videoElement.srcObject = remoteStream;
});
```
## TypeScript
@@ -408,45 +653,40 @@ All types are exported:
```typescript
import type {
Credentials,
Offer,
CreateOfferRequest,
TopicInfo,
IceCandidate,
FetchFunction,
RondevuOptions,
ConnectionOptions,
RondevuConnectionEvents
// Username types
UsernameCheckResult,
UsernameClaimResult,
Keypair,
// Service types
ServicePublishResult,
PublishServiceOptions,
ServiceHandle,
// Discovery types
ServiceInfo,
ServiceListResult,
ServiceQueryResult,
ServiceDetails,
ConnectResult,
// Peer types
PeerOptions,
PeerEvents,
PeerTimeouts
} from '@xtr-dev/rondevu-client';
```
## Environment Compatibility
## Migration from V1
The client library is designed to work across different JavaScript runtimes:
V2 is a **breaking change** that replaces topic-based discovery with username claiming and service publishing. See the main [MIGRATION.md](../MIGRATION.md) for detailed migration guide.
| Environment | Native Fetch | Custom Fetch Needed |
|-------------|--------------|---------------------|
| Modern Browsers | ✅ Yes | ❌ No |
| Node.js 18+ | ✅ Yes | ❌ No |
| Node.js < 18 | ❌ No | ✅ Yes (node-fetch) |
| Deno | ✅ Yes | ❌ No |
| Bun | ✅ Yes | ❌ No |
| Cloudflare Workers | ✅ Yes | ❌ No |
**If your environment doesn't have native fetch:**
```bash
npm install node-fetch
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
fetch: fetch as any
});
```
**Key Changes:**
- ❌ Removed: `offers.findByTopic()`, `offers.getTopics()`, bloom filters
- ✅ Added: `usernames.*`, `services.*`, `discovery.*` APIs
- ✅ Changed: Focus on service-based discovery instead of topics
## License

14
package-lock.json generated
View File

@@ -1,20 +1,30 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.7.0",
"version": "0.7.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.7.0",
"version": "0.7.12",
"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.0",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
"version": "0.8.3",
"description": "TypeScript client for Rondevu DNS-like WebRTC with username claiming and service discovery",
"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;
}
}

View File

@@ -1,208 +0,0 @@
import {
RondevuClientOptions,
CreateOfferRequest,
CreateOfferResponse,
AnswerRequest,
AnswerResponse,
PollRequest,
PollOffererResponse,
PollAnswererResponse,
VersionResponse,
HealthResponse,
ErrorResponse,
Side,
} from './types.js';
/**
* HTTP API client for Rondevu peer signaling server
*/
export class RondevuAPI {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
/**
* Creates a new Rondevu API client instance
* @param options - Client configuration options
*/
constructor(options: RondevuClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.fetchImpl = options.fetch || globalThis.fetch.bind(globalThis);
}
/**
* Makes an HTTP request to the Rondevu server
*/
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await this.fetchImpl(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
const error = data as ErrorResponse;
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return data as T;
}
/**
* Gets server version information
*
* @returns Server version
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { version } = await api.getVersion();
* console.log('Server version:', version);
* ```
*/
async getVersion(): Promise<VersionResponse> {
return this.request<VersionResponse>('/', {
method: 'GET',
});
}
/**
* Creates a new offer
*
* @param request - Offer details including peer ID, signaling data, and optional custom code
* @returns Unique offer code (UUID or custom code)
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const { code } = await api.createOffer({
* peerId: 'peer-123',
* offer: signalingData,
* code: 'my-custom-code' // optional
* });
* console.log('Offer code:', code);
* ```
*/
async createOffer(request: CreateOfferRequest): Promise<CreateOfferResponse> {
return this.request<CreateOfferResponse>('/offer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Sends an answer or candidate to an existing offer
*
* @param request - Answer details including offer code and signaling data
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Send answer
* await api.sendAnswer({
* code: offerCode,
* answer: answerData,
* side: 'answerer'
* });
*
* // Send candidate
* await api.sendAnswer({
* code: offerCode,
* candidate: candidateData,
* side: 'offerer'
* });
* ```
*/
async sendAnswer(request: AnswerRequest): Promise<AnswerResponse> {
return this.request<AnswerResponse>('/answer', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Polls for offer data from the other peer
*
* @param code - Offer code
* @param side - Which side is polling ('offerer' or 'answerer')
* @returns Offer data including offers, answers, and candidates
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
*
* // Offerer polls for answer
* const offererData = await api.poll(offerCode, 'offerer');
* if (offererData.answer) {
* console.log('Received answer:', offererData.answer);
* }
*
* // Answerer polls for offer
* const answererData = await api.poll(offerCode, 'answerer');
* console.log('Received offer:', answererData.offer);
* ```
*/
async poll(
code: string,
side: Side
): Promise<PollOffererResponse | PollAnswererResponse> {
const request: PollRequest = { code, side };
return this.request<PollOffererResponse | PollAnswererResponse>('/poll', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Checks server health and version
*
* @returns Health status, timestamp, and version
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* const health = await api.health();
* console.log('Server status:', health.status);
* console.log('Server version:', health.version);
* ```
*/
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health', {
method: 'GET',
});
}
/**
* Ends a session by deleting the offer from the server
*
* @param code - The offer code
* @returns Success confirmation
*
* @example
* ```typescript
* const api = new RondevuAPI({ baseUrl: 'https://example.com' });
* await api.leave('my-offer-code');
* ```
*/
async leave(code: string): Promise<{ success: boolean }> {
return this.request<{ success: boolean }>('/leave', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
}

276
src/discovery.ts Normal file
View File

@@ -0,0 +1,276 @@
import RondevuPeer from './peer/index.js';
import { RondevuOffers } from './offers.js';
/**
* Service info from discovery
*/
export interface ServiceInfo {
uuid: string;
isPublic: boolean;
serviceFqn?: string;
metadata?: Record<string, any>;
}
/**
* Service list result
*/
export interface ServiceListResult {
username: string;
services: ServiceInfo[];
}
/**
* Service query result
*/
export interface ServiceQueryResult {
uuid: string;
allowed: boolean;
}
/**
* Service details
*/
export interface ServiceDetails {
serviceId: string;
username: string;
serviceFqn: string;
offerId: string;
sdp: string;
isPublic: boolean;
metadata?: Record<string, any>;
createdAt: number;
expiresAt: number;
}
/**
* Connect result
*/
export interface ConnectResult {
peer: RondevuPeer;
channel: RTCDataChannel;
}
/**
* Rondevu Discovery API
* Handles service discovery and connections
*/
export class RondevuDiscovery {
private offersApi: RondevuOffers;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string }
) {
this.offersApi = new RondevuOffers(baseUrl, credentials);
}
/**
* Lists all services for a username
* Returns UUIDs only for private services, full details for public
*/
async listServices(username: string): Promise<ServiceListResult> {
const response = await fetch(`${this.baseUrl}/usernames/${username}/services`);
if (!response.ok) {
throw new Error('Failed to list services');
}
const data = await response.json();
return {
username: data.username,
services: data.services
};
}
/**
* Queries a service by FQN
* Returns UUID if service exists and is allowed
*/
async queryService(username: string, serviceFqn: string): Promise<ServiceQueryResult> {
const response = await fetch(`${this.baseUrl}/index/${username}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceFqn })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Service not found');
}
const data = await response.json();
return {
uuid: data.uuid,
allowed: data.allowed
};
}
/**
* Gets service details by UUID
*/
async getServiceDetails(uuid: string): Promise<ServiceDetails> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Service not found');
}
const data = await response.json();
return {
serviceId: data.serviceId,
username: data.username,
serviceFqn: data.serviceFqn,
offerId: data.offerId,
sdp: data.sdp,
isPublic: data.isPublic,
metadata: data.metadata,
createdAt: data.createdAt,
expiresAt: data.expiresAt
};
}
/**
* Connects to a service by UUID
*/
async connectToService(
uuid: string,
options?: {
rtcConfig?: RTCConfiguration;
onConnected?: () => void;
onData?: (data: any) => void;
}
): Promise<RondevuPeer> {
// Get service details
const service = await this.getServiceDetails(uuid);
// Create peer with the offer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Set up event handlers
if (options?.onConnected) {
peer.on('connected', options.onConnected);
}
if (options?.onData) {
peer.on('datachannel', (channel: RTCDataChannel) => {
channel.onmessage = (e) => options.onData!(e.data);
});
}
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
return peer;
}
/**
* Convenience method: Query and connect in one call
* Returns both peer and data channel
*/
async connect(
username: string,
serviceFqn: string,
options?: {
rtcConfig?: RTCConfiguration;
}
): Promise<ConnectResult> {
// Query service
const query = await this.queryService(username, serviceFqn);
if (!query.allowed) {
throw new Error('Service access denied');
}
// Get service details
const service = await this.getServiceDetails(query.uuid);
// Create peer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
// Wait for data channel
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for data channel'));
}, 30000);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
peer.on('failed', (error: Error) => {
clearTimeout(timeout);
reject(error);
});
});
return { peer, channel };
}
/**
* Convenience method: Connect to service by UUID with channel
*/
async connectByUuid(
uuid: string,
options?: { rtcConfig?: RTCConfiguration }
): Promise<ConnectResult> {
// Get service details
const service = await this.getServiceDetails(uuid);
// Create peer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
// Wait for data channel
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for data channel'));
}, 30000);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
peer.on('failed', (error: Error) => {
clearTimeout(timeout);
reject(error);
});
});
return { peer, channel };
}
}

View File

@@ -20,9 +20,6 @@ export type {
TopicInfo
} from './offers.js';
// Export bloom filter
export { BloomFilter } from './bloom.js';
// Export peer manager
export { default as RondevuPeer } from './peer/index.js';
export type {
@@ -30,3 +27,28 @@ export type {
PeerEvents,
PeerTimeouts
} from './peer/index.js';
// Export username API
export { RondevuUsername } from './usernames.js';
export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js';
// Export services API
export { RondevuServices } from './services.js';
export type {
ServicePublishResult,
PublishServiceOptions,
ServiceHandle
} from './services.js';
// Export discovery API
export { RondevuDiscovery } from './discovery.js';
export type {
ServiceInfo,
ServiceListResult,
ServiceQueryResult,
ServiceDetails,
ConnectResult
} from './discovery.js';
// Export pool types
export type { PoolStatus, PooledServiceHandle } from './service-pool.js';

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

@@ -0,0 +1,174 @@
import { RondevuOffers, Offer } from './offers.js';
/**
* Represents an offer that has been answered
*/
export interface AnsweredOffer {
offerId: string;
answererId: string;
sdp: string;
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<Offer[]>;
/** 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 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
*/
async addOffers(offers: Offer[]): Promise<void> {
for (const offer of offers) {
this.offers.set(offer.id, offer);
}
}
/**
* 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) {
// Notify ServicePool
await this.options.onAnswered({
offerId: answer.offerId,
answererId: answer.answererId,
sdp: answer.sdp,
answeredAt: answer.answeredAt
});
// Remove consumed offer from pool
this.offers.delete(answer.offerId);
}
// Immediate refill if below pool size
if (this.offers.size < this.options.poolSize) {
const needed = this.options.poolSize - this.offers.size;
try {
const newOffers = await this.options.onRefill(needed);
await this.addOffers(newOffers);
} 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 the last poll timestamp
*/
getLastPollTime(): number {
return this.lastPollTime;
}
/**
* Check if the pool is currently polling
*/
isPolling(): boolean {
return this.polling;
}
}

View File

@@ -8,6 +8,8 @@ export interface CreateOfferRequest {
sdp: string;
topics: string[];
ttl?: number;
secret?: string;
info?: string;
}
export interface Offer {
@@ -18,6 +20,9 @@ export interface Offer {
createdAt?: number;
expiresAt: number;
lastSeen: number;
secret?: string;
hasSecret?: boolean;
info?: string;
answererPeerId?: string;
answerSdp?: string;
answeredAt?: number;
@@ -143,11 +148,13 @@ export class RondevuOffers {
async getTopics(options?: {
limit?: number;
offset?: number;
startsWith?: string;
}): Promise<{
topics: TopicInfo[];
total: number;
limit: number;
offset: number;
startsWith?: string;
}> {
const params = new URLSearchParams();
@@ -159,6 +166,10 @@ export class RondevuOffers {
params.set('offset', options.offset.toString());
}
if (options?.startsWith) {
params.set('startsWith', options.startsWith);
}
const url = `${this.baseUrl}/topics${
params.toString() ? '?' + params.toString() : ''
}`;
@@ -195,23 +206,6 @@ export class RondevuOffers {
return data.offers;
}
/**
* Update offer heartbeat
*/
async heartbeat(offerId: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/heartbeat`, {
method: 'PUT',
headers: {
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to update heartbeat: ${error.error || response.statusText}`);
}
}
/**
* Delete an offer
*/
@@ -232,14 +226,14 @@ export class RondevuOffers {
/**
* Answer an offer
*/
async answer(offerId: string, sdp: string): Promise<void> {
async answer(offerId: string, sdp: string, secret?: string): Promise<void> {
const response = await this.fetchFn(`${this.baseUrl}/offers/${encodeURIComponent(offerId)}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: RondevuAuth.createAuthHeader(this.credentials),
},
body: JSON.stringify({ sdp }),
body: JSON.stringify({ sdp, secret }),
});
if (!response.ok) {

View File

@@ -25,25 +25,18 @@ export class AnsweringState extends PeerState {
// Create answer
const answer = await this.peer.pc.createAnswer();
// Send answer to server BEFORE setLocalDescription
// This registers us as the answerer so ICE candidates will be accepted
await this.peer.offersApi.answer(offerId, answer.sdp!, options.secret);
// Enable trickle ICE - set up handler before ICE gathering starts
this.setupIceCandidateHandler();
// Set local description - ICE gathering starts here
// Server already knows we're the answerer, so candidates will be accepted
await this.peer.pc.setLocalDescription(answer);
// Send answer to server immediately (don't wait for ICE)
await this.peer.offersApi.answer(offerId, answer.sdp!);
// Enable trickle ICE - send candidates as they arrive
this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
// Transition to exchanging ICE
const { ExchangingIceState } = await import('./exchanging-ice-state.js');
this.peer.setState(new ExchangingIceState(this.peer, offerId, options));

View File

@@ -24,33 +24,24 @@ export class CreatingOfferState extends PeerState {
this.peer.emitEvent('datachannel', channel);
}
// Enable trickle ICE - set up handler before ICE gathering starts
// Handler will check this.peer.offerId before sending
this.setupIceCandidateHandler();
// Create WebRTC offer
const offer = await this.peer.pc.createOffer();
await this.peer.pc.setLocalDescription(offer);
await this.peer.pc.setLocalDescription(offer); // ICE gathering starts here
// Send offer to server immediately (don't wait for ICE)
const offers = await this.peer.offersApi.create([{
sdp: offer.sdp!,
topics: options.topics,
ttl: options.ttl || 300000
ttl: options.ttl || 300000,
secret: options.secret
}]);
const offerId = offers[0].id;
this.peer.offerId = offerId;
// Enable trickle ICE - send candidates as they arrive
this.peer.pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
this.peer.offerId = offerId; // Now handler can send candidates
// Transition to waiting for answer
const { WaitingForAnswerState } = await import('./waiting-for-answer-state.js');

View File

@@ -43,7 +43,7 @@ export class ExchangingIceState extends PeerState {
for (const cand of candidates) {
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
try {
await this.peer.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
await this.peer.pc.addIceCandidate(new this.peer.RTCIceCandidate(cand.candidate));
this.lastIceTimestamp = cand.createdAt;
} catch (err) {
console.warn('Failed to add ICE candidate:', err);

View File

@@ -24,8 +24,19 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
offerId?: string;
role?: 'offerer' | 'answerer';
// WebRTC polyfills for Node.js compatibility
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
private _state: PeerState;
// Event handler references for cleanup
private connectionStateChangeHandler?: () => void;
private dataChannelHandler?: (event: RTCDataChannelEvent) => void;
private trackHandler?: (event: RTCTrackEvent) => void;
private iceCandidateErrorHandler?: (event: Event) => void;
/**
* Current connection state name
*/
@@ -54,11 +65,34 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
}
},
rtcPeerConnection?: typeof RTCPeerConnection,
rtcSessionDescription?: typeof RTCSessionDescription,
rtcIceCandidate?: typeof RTCIceCandidate
) {
super();
this.offersApi = offersApi;
this.pc = new RTCPeerConnection(rtcConfig);
// Use provided polyfills or fall back to globals
this.RTCPeerConnection = rtcPeerConnection || (typeof globalThis.RTCPeerConnection !== 'undefined'
? globalThis.RTCPeerConnection
: (() => {
throw new Error('RTCPeerConnection is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCSessionDescription = rtcSessionDescription || (typeof globalThis.RTCSessionDescription !== 'undefined'
? globalThis.RTCSessionDescription
: (() => {
throw new Error('RTCSessionDescription is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.RTCIceCandidate = rtcIceCandidate || (typeof globalThis.RTCIceCandidate !== 'undefined'
? globalThis.RTCIceCandidate
: (() => {
throw new Error('RTCIceCandidate is not available. Please provide it in the Rondevu constructor options for Node.js environments.');
}) as any);
this.pc = new this.RTCPeerConnection(rtcConfig);
this._state = new IdleState(this);
this.setupPeerConnection();
@@ -68,7 +102,7 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
* Set up peer connection event handlers
*/
private setupPeerConnection(): void {
this.pc.onconnectionstatechange = () => {
this.connectionStateChangeHandler = () => {
switch (this.pc.connectionState) {
case 'connected':
this.setState(new ConnectedState(this));
@@ -86,18 +120,22 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
break;
}
};
this.pc.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
this.pc.ondatachannel = (event) => {
this.dataChannelHandler = (event: RTCDataChannelEvent) => {
this.emitEvent('datachannel', event.channel);
};
this.pc.addEventListener('datachannel', this.dataChannelHandler);
this.pc.ontrack = (event) => {
this.trackHandler = (event: RTCTrackEvent) => {
this.emitEvent('track', event);
};
this.pc.addEventListener('track', this.trackHandler);
this.pc.onicecandidateerror = (event) => {
this.iceCandidateErrorHandler = (event: Event) => {
console.error('ICE candidate error:', event);
};
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
/**
@@ -141,10 +179,33 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
return this.pc.addTrack(track, ...streams);
}
/**
* Create a data channel for sending and receiving arbitrary data
* This should typically be called by the offerer before creating the offer
* The answerer will receive the channel via the 'datachannel' event
*/
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
return this.pc.createDataChannel(label, options);
}
/**
* Close the connection and clean up
*/
async close(): Promise<void> {
// Remove RTCPeerConnection event listeners
if (this.connectionStateChangeHandler) {
this.pc.removeEventListener('connectionstatechange', this.connectionStateChangeHandler);
}
if (this.dataChannelHandler) {
this.pc.removeEventListener('datachannel', this.dataChannelHandler);
}
if (this.trackHandler) {
this.pc.removeEventListener('track', this.trackHandler);
}
if (this.iceCandidateErrorHandler) {
this.pc.removeEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}
await this._state.close();
this.removeAllListeners();
}

View File

@@ -6,6 +6,8 @@ import type RondevuPeer from './index.js';
* Implements the State pattern for managing WebRTC connection lifecycle
*/
export abstract class PeerState {
protected iceCandidateHandler?: (event: RTCPeerConnectionIceEvent) => void;
constructor(protected peer: RondevuPeer) {}
abstract get name(): string;
@@ -25,12 +27,35 @@ export abstract class PeerState {
async handleIceCandidate(candidate: any): Promise<void> {
// ICE candidates can arrive in multiple states, so default is to add them
if (this.peer.pc.remoteDescription) {
await this.peer.pc.addIceCandidate(new RTCIceCandidate(candidate));
await this.peer.pc.addIceCandidate(new this.peer.RTCIceCandidate(candidate));
}
}
/**
* Setup trickle ICE candidate handler
* Sends local ICE candidates to server as they are discovered
*/
protected setupIceCandidateHandler(): void {
this.iceCandidateHandler = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && this.peer.offerId) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
try {
await this.peer.offersApi.addIceCandidates(this.peer.offerId, [candidateData]);
} catch (err) {
console.error('Error sending ICE candidate:', err);
}
}
}
};
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);
}
cleanup(): void {
// Override in states that need cleanup
// Clean up ICE candidate handler if it exists
if (this.iceCandidateHandler) {
this.peer.pc.removeEventListener('icecandidate', this.iceCandidateHandler);
}
}
async close(): Promise<void> {

View File

@@ -22,6 +22,8 @@ export interface PeerOptions {
topics: string[];
/** How long the offer should live (milliseconds) */
ttl?: number;
/** Optional secret to protect the offer (max 128 characters) */
secret?: string;
/** Whether to create a data channel automatically (for offerer) */
createDataChannel?: boolean;
/** Label for the automatically created data channel */

View File

@@ -1,5 +1,8 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js';
import { RondevuUsername } from './usernames.js';
import { RondevuServices } from './services.js';
import { RondevuDiscovery } from './discovery.js';
import RondevuPeer from './peer/index.js';
export interface RondevuOptions {
@@ -25,29 +28,79 @@ export interface RondevuOptions {
* ```
*/
fetch?: FetchFunction;
/**
* Custom RTCPeerConnection implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCPeerConnection } from 'wrtc';
* const client = new Rondevu({ RTCPeerConnection });
* ```
*/
RTCPeerConnection?: typeof RTCPeerConnection;
/**
* Custom RTCSessionDescription implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCSessionDescription } from 'wrtc';
* const client = new Rondevu({ RTCSessionDescription });
* ```
*/
RTCSessionDescription?: typeof RTCSessionDescription;
/**
* Custom RTCIceCandidate implementation for Node.js environments
* Required when using in Node.js with wrtc or similar polyfills
*
* @example Node.js with wrtc
* ```typescript
* import { RTCIceCandidate } from 'wrtc';
* const client = new Rondevu({ RTCIceCandidate });
* ```
*/
RTCIceCandidate?: typeof RTCIceCandidate;
}
export class Rondevu {
readonly auth: RondevuAuth;
readonly usernames: RondevuUsername;
private _offers?: RondevuOffers;
private _services?: RondevuServices;
private _discovery?: RondevuDiscovery;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
private rtcPeerConnection?: typeof RTCPeerConnection;
private rtcSessionDescription?: typeof RTCSessionDescription;
private rtcIceCandidate?: typeof RTCIceCandidate;
constructor(options: RondevuOptions = {}) {
this.baseUrl = options.baseUrl || 'https://api.ronde.vu';
this.fetchFn = options.fetch;
this.rtcPeerConnection = options.RTCPeerConnection;
this.rtcSessionDescription = options.RTCSessionDescription;
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;
this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
this._services = new RondevuServices(this.baseUrl, this.credentials);
this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials);
}
}
/**
* Get offers API (requires authentication)
* Get offers API (low-level access, requires authentication)
* For most use cases, use services and discovery APIs instead
*/
get offers(): RondevuOffers {
if (!this._offers) {
@@ -56,18 +109,41 @@ export class Rondevu {
return this._offers;
}
/**
* Get services API (requires authentication)
*/
get services(): RondevuServices {
if (!this._services) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._services;
}
/**
* Get discovery API (requires authentication)
*/
get discovery(): RondevuDiscovery {
if (!this._discovery) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._discovery;
}
/**
* Register and initialize authenticated client
* Generates a cryptographically random peer ID (128-bit)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create offers API instance
// Create API instances
this._offers = new RondevuOffers(
this.baseUrl,
this.credentials,
this.fetchFn
);
this._services = new RondevuServices(this.baseUrl, this.credentials);
this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials);
return this.credentials;
}
@@ -98,6 +174,12 @@ export class Rondevu {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return new RondevuPeer(this._offers, rtcConfig);
return new RondevuPeer(
this._offers,
rtcConfig,
this.rtcPeerConnection,
this.rtcSessionDescription,
this.rtcIceCandidate
);
}
}

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

@@ -0,0 +1,490 @@
import { RondevuOffers, Offer } from './offers.js';
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { OfferPool, AnsweredOffer } from './offer-pool.js';
import { ServiceHandle } from './services.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;
}
/**
* Extended service handle with pool-specific methods
*/
export interface PooledServiceHandle extends ServiceHandle {
/** 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 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[] = [];
if (poolSize > 1) {
try {
const offers = await this.createOffers(poolSize - 1);
additionalOffers.push(...offers);
} 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
const allOffers = [
{ id: service.offerId, peerId: this.credentials.peerId, sdp: '', topics: [], expiresAt: service.expiresAt, lastSeen: Date.now() },
...additionalOffers
];
await this.offerPool.addOffers(allOffers);
// 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. Delete remaining offers
if (this.offerPool) {
const offerIds = this.offerPool.getActiveOfferIds();
await Promise.allSettled(
offerIds.map(id => this.offersApi.delete(id).catch(() => {}))
);
}
// 3. 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);
// 4. 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 {
// Create peer connection
const peer = new RondevuPeer(
this.offersApi,
this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
peer.role = 'offerer';
peer.offerId = answer.offerId;
// Set remote description (the answer)
await peer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
// Wait for data channel (answerer creates it, we receive it)
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timeout waiting for data channel')),
30000
);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
// Also check if channel already exists
if (peer.pc.ondatachannel) {
const existingHandler = peer.pc.ondatachannel;
peer.pc.ondatachannel = (event) => {
clearTimeout(timeout);
resolve(event.channel);
if (existingHandler) existingHandler.call(peer.pc, event);
};
} else {
peer.pc.ondatachannel = (event) => {
clearTimeout(timeout);
resolve(event.channel);
};
}
});
// 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<Offer[]> {
if (count <= 0) {
return [];
}
// Server supports max 10 offers per request
const batchSize = Math.min(count, 10);
const offers: Offer[] = [];
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)
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');
}
offerRequests.push({
sdp: offer.sdp,
topics: [], // V2 doesn't use topics
ttl: this.options.ttl
});
// Close the PC immediately - we only needed the SDP
pc.close();
}
// Batch create offers
const createdOffers = await this.offersApi.create(offerRequests);
offers.push(...createdOffers);
} catch (error) {
this.status.failedOfferCreations++;
this.handleError(error as Error, 'offer-creation');
throw error;
}
return offers;
}
/**
* Publish the initial service (creates first offer)
*/
private async publishInitialService(): Promise<{
serviceId: string;
uuid: string;
offerId: string;
expiresAt: number;
}> {
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' }]
});
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');
}
// 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: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
pc.close();
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to publish service');
}
const data = await response.json();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
expiresAt: data.expiresAt
};
}
/**
* Manually add offers to the pool
*/
private async manualRefill(count: number): Promise<void> {
if (!this.offerPool) {
throw new Error('Pool not started');
}
const offers = await this.createOffers(count);
await this.offerPool.addOffers(offers);
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)}`;
}
}

308
src/services.ts Normal file
View File

@@ -0,0 +1,308 @@
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { RondevuOffers } from './offers.js';
import { ServicePool, ServicePoolOptions, PooledServiceHandle, PoolStatus } from './service-pool.js';
/**
* Service publish result
*/
export interface ServicePublishResult {
serviceId: string;
uuid: string;
offerId: string;
expiresAt: number;
}
/**
* Service publish options
*/
export interface PublishServiceOptions {
username: string;
privateKey: string;
serviceFqn: string;
rtcConfig?: RTCConfiguration;
isPublic?: boolean;
metadata?: Record<string, any>;
ttl?: number;
onConnection?: (peer: RondevuPeer) => void;
}
/**
* Service handle for managing an exposed service
*/
export interface ServiceHandle {
serviceId: string;
uuid: string;
offerId: string;
unpublish: () => Promise<void>;
}
/**
* Rondevu Services API
* Handles service publishing and management
*/
export class RondevuServices {
private usernameApi: RondevuUsername;
private offersApi: RondevuOffers;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string }
) {
this.usernameApi = new RondevuUsername(baseUrl);
this.offersApi = new RondevuOffers(baseUrl, credentials);
}
/**
* Publishes a service
*/
async publishService(options: PublishServiceOptions): Promise<ServicePublishResult> {
const {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic = false,
metadata,
ttl
} = options;
// Validate FQN format
this.validateServiceFqn(serviceFqn);
// Create WebRTC peer connection to generate offer
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Add a data channel (required for datachannel-based services)
pc.createDataChannel('rondevu-service');
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
throw new Error('Failed to generate SDP');
}
// Create signature for username verification
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: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
pc.close();
throw new Error(error.error || 'Failed to publish service');
}
const data = await response.json();
// Close the connection for now (would be kept open in a real implementation)
pc.close();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
expiresAt: data.expiresAt
};
}
/**
* Unpublishes a service
*/
async unpublishService(serviceId: string, username: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/services/${serviceId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({ username })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to unpublish service');
}
}
/**
* Exposes a service with an automatic connection handler
* This is a convenience method that publishes the service and manages connections
*
* Set poolSize > 1 to enable offer pooling for handling multiple concurrent connections
*/
async exposeService(options: Omit<PublishServiceOptions, 'onConnection'> & {
handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId?: string) => void;
poolSize?: number;
pollingInterval?: number;
onPoolStatus?: (status: PoolStatus) => void;
onError?: (error: Error, context: string) => void;
}): Promise<ServiceHandle | PooledServiceHandle> {
const {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic,
metadata,
ttl,
handler,
poolSize,
pollingInterval,
onPoolStatus,
onError
} = options;
// If poolSize > 1, use pooled implementation
if (poolSize && poolSize > 1) {
const pool = new ServicePool(this.baseUrl, this.credentials, {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic,
metadata,
ttl,
handler: (channel, peer, connectionId) => handler(channel, peer, connectionId),
poolSize,
pollingInterval,
onPoolStatus,
onError
});
return await pool.start();
}
// Otherwise, use existing single-offer logic (UNCHANGED)
// Validate FQN
this.validateServiceFqn(serviceFqn);
// Create peer connection
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Create data channel
const channel = pc.createDataChannel('rondevu-service');
// Set up handler
channel.onopen = () => {
const peer = new RondevuPeer(
this.offersApi,
rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
handler(channel, peer);
};
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate 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: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
pc.close();
throw new Error(error.error || 'Failed to expose service');
}
const data = await response.json();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
unpublish: () => this.unpublishService(data.serviceId, username)
};
}
/**
* Validates service FQN format
*/
private validateServiceFqn(fqn: string): void {
const parts = fqn.split('@');
if (parts.length !== 2) {
throw new Error('Service FQN must be in format: service-name@version');
}
const [serviceName, version] = parts;
// Validate service name (reverse domain notation)
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
if (!serviceNameRegex.test(serviceName)) {
throw new Error('Service name must be reverse domain notation (e.g., com.example.service)');
}
if (serviceName.length < 3 || serviceName.length > 128) {
throw new Error('Service name must be 3-128 characters');
}
// Validate version (semantic versioning)
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
if (!versionRegex.test(version)) {
throw new Error('Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)');
}
}
/**
* Parses a service FQN into name and version
*/
parseServiceFqn(fqn: string): { name: string; version: string } {
const parts = fqn.split('@');
if (parts.length !== 2) {
throw new Error('Invalid FQN format');
}
return { name: parts[0], version: parts[1] };
}
}

View File

@@ -1,182 +0,0 @@
// ============================================================================
// Signaling Types
// ============================================================================
/**
* Session side - identifies which peer in a connection
*/
export type Side = 'offerer' | 'answerer';
/**
* Request body for POST /offer
*/
export interface CreateOfferRequest {
/** Peer identifier/metadata (max 1024 characters) */
peerId: string;
/** Signaling data for peer connection */
offer: string;
/** Optional custom connection code (if not provided, server generates UUID) */
code?: string;
}
/**
* Response from POST /offer
*/
export interface CreateOfferResponse {
/** Unique session identifier (UUID) */
code: string;
}
/**
* Request body for POST /answer
*/
export interface AnswerRequest {
/** Session UUID from the offer */
code: string;
/** Response signaling data (required if candidate not provided) */
answer?: string;
/** Additional signaling data (required if answer not provided) */
candidate?: string;
/** Which peer is sending the data */
side: Side;
}
/**
* Response from POST /answer
*/
export interface AnswerResponse {
success: boolean;
}
/**
* Request body for POST /poll
*/
export interface PollRequest {
/** Session UUID */
code: string;
/** Which side is polling */
side: Side;
}
/**
* Response from POST /poll when side=offerer
*/
export interface PollOffererResponse {
/** Answer from answerer (null if not yet received) */
answer: string | null;
/** Additional signaling data from answerer */
answerCandidates: string[];
}
/**
* Response from POST /poll when side=answerer
*/
export interface PollAnswererResponse {
/** Offer from offerer */
offer: string;
/** Additional signaling data from offerer */
offerCandidates: string[];
}
/**
* Response from POST /poll (union type)
*/
export type PollResponse = PollOffererResponse | PollAnswererResponse;
/**
* Response from GET / - server version information
*/
export interface VersionResponse {
/** Git commit hash or version identifier */
version: string;
}
/**
* Response from GET /health
*/
export interface HealthResponse {
status: 'ok';
timestamp: number;
version: string;
}
/**
* Error response structure
*/
export interface ErrorResponse {
error: string;
}
/**
* Client configuration options
*/
export interface RondevuClientOptions {
/** Base URL of the Rondevu server (e.g., 'https://example.com') */
baseUrl: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
}
// ============================================================================
// WebRTC Types
// ============================================================================
/**
* WebRTC polyfill for Node.js and other non-browser platforms
*/
export interface WebRTCPolyfill {
RTCPeerConnection: typeof RTCPeerConnection;
RTCSessionDescription: typeof RTCSessionDescription;
RTCIceCandidate: typeof RTCIceCandidate;
}
/**
* Configuration options for Rondevu WebRTC client
*/
export interface RondevuOptions {
/** Base URL of the Rondevu server (defaults to 'https://api.ronde.vu') */
baseUrl?: string;
/** Peer identifier (optional, auto-generated if not provided) */
peerId?: string;
/** Optional fetch implementation (for Node.js environments) */
fetch?: typeof fetch;
/** WebRTC configuration (ICE servers, etc.) */
rtcConfig?: RTCConfiguration;
/** Polling interval in milliseconds (default: 1000) */
pollingInterval?: number;
/** Connection timeout in milliseconds (default: 30000) */
connectionTimeout?: number;
/** WebRTC polyfill for Node.js (e.g., wrtc or @roamhq/wrtc) */
wrtc?: WebRTCPolyfill;
}
/**
* Connection role - whether this peer is creating or answering
*/
export type ConnectionRole = 'offerer' | 'answerer';
/**
* Parameters for creating a RondevuConnection
*/
export interface RondevuConnectionParams {
id: string;
topic?: string;
role: ConnectionRole;
pc: RTCPeerConnection;
localPeerId: string;
remotePeerId: string;
pollingInterval: number;
connectionTimeout: number;
wrtc?: WebRTCPolyfill;
}
/**
* Event map for RondevuConnection events
*/
export interface RondevuConnectionEvents {
connect: () => void;
disconnect: () => void;
error: (error: Error) => void;
datachannel: (channel: RTCDataChannel) => void;
stream: (stream: MediaStream) => void;
}

200
src/usernames.ts Normal file
View File

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