From 15f821f08ae4a975594d95b4d8a08bd10031119a Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 5 Dec 2025 18:26:23 +0100 Subject: [PATCH] feat: implement offer pooling for multi-connection services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 915 ++++++++++++++++++++++++-------------------- package-lock.json | 14 +- package.json | 6 +- src/bloom.ts | 83 ---- src/discovery.ts | 276 +++++++++++++ src/index.ts | 3 - src/offer-pool.ts | 174 +++++++++ src/rondevu.ts | 37 +- src/service-pool.ts | 490 ++++++++++++++++++++++++ src/services.ts | 308 +++++++++++++++ src/usernames.ts | 193 ++++++++++ 11 files changed, 1981 insertions(+), 518 deletions(-) delete mode 100644 src/bloom.ts create mode 100644 src/discovery.ts create mode 100644 src/offer-pool.ts create mode 100644 src/service-pool.ts create mode 100644 src/services.ts create mode 100644 src/usernames.ts diff --git a/README.md b/README.md index 92e8f84..dc7fd9b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) -🌐 **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 with trickle ICE support. +TypeScript/JavaScript client for Rondevu, providing cryptographic username claiming, service publishing, and privacy-preserving discovery. **Related repositories:** - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) @@ -15,14 +15,12 @@ TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, ## Features -- **Topic-Based Discovery**: Find peers by topics (e.g., torrent infohashes) -- **Stateless Authentication**: No server-side sessions, portable credentials -- **Protected Connections**: Optional secret-protected offers for access control -- **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 (faster connections) -- **State Machine**: Clean state-based connection lifecycle +- **Trickle ICE**: Send ICE candidates as they're discovered - **TypeScript**: Full type safety and autocomplete ## Install @@ -33,7 +31,7 @@ npm install @xtr-dev/rondevu-client ## Quick Start -### Creating an Offer (Peer A) +### Publishing a Service (Alice) ```typescript import { Rondevu } from '@xtr-dev/rondevu-client'; @@ -42,43 +40,43 @@ import { Rondevu } from '@xtr-dev/rondevu-client'; const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); await client.register(); -// Create peer connection -const peer = client.createPeer(); +// 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 -peer.on('state', (state) => { - console.log('Peer state:', state); - // States: idle → creating-offer → waiting-for-answer → exchanging-ice → connected +console.log(`Username claimed: ${claim.username}`); +console.log(`Expires: ${new Date(claim.expiresAt)}`); + +// Step 2: Expose service with handler +const keypair = client.usernames.loadKeypairFromStorage('alice'); + +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.onopen = () => { + console.log('✅ Data channel open'); + }; + } }); -peer.on('connected', () => { - console.log('✅ Connected to peer!'); -}); +console.log(`Service published with UUID: ${handle.uuid}`); +console.log('Waiting for connections...'); -peer.on('datachannel', (channel) => { - console.log('📡 Data channel ready'); - - channel.addEventListener('message', (event) => { - console.log('📥 Received:', event.data); - }); - - channel.addEventListener('open', () => { - channel.send('Hello from peer A!'); - }); -}); - -// Create offer and advertise on topics -const offerId = await peer.createOffer({ - topics: ['my-app', 'room-123'], - ttl: 300000, // 5 minutes - secret: 'my-secret-password' // Optional: protect offer (max 128 chars) -}); - -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'; @@ -87,188 +85,296 @@ import { Rondevu } from '@xtr-dev/rondevu-client'; 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 peer connection - const peer = client.createPeer(); - - // Set up event listeners - peer.on('state', (state) => { - console.log('Peer state:', state); - // States: idle → answering → exchanging-ice → connected - }); - - peer.on('connected', () => { - console.log('✅ Connected!'); - }); - - peer.on('datachannel', (channel) => { - console.log('📡 Data channel ready'); - - channel.addEventListener('message', (event) => { - console.log('📥 Received:', event.data); - }); - - channel.addEventListener('open', () => { - channel.send('Hello from peer B!'); - }); - }); - - peer.on('failed', (error) => { - console.error('❌ Connection failed:', error); - }); - - // Answer the offer - await peer.answer(offer.id, offer.sdp, { - topics: offer.topics, - secret: 'my-secret-password' // Required if offer.hasSecret is true - }); -} -``` - -## Protected Offers - -You can protect offers with a secret to control who can answer them. This is useful for private rooms or invite-only connections. - -### Creating a Protected Offer - -```typescript -const offerId = await peer.createOffer({ - topics: ['private-room'], - secret: 'my-secret-password' // Max 128 characters -}); - -// Share the secret with authorized peers through a secure channel -``` - -### Answering a Protected Offer - -```typescript -const offers = await client.offers.findByTopic('private-room'); - -// Check if offer requires a secret -if (offers[0].hasSecret) { - console.log('This offer requires a secret'); -} - -// Provide the secret when answering -await peer.answer(offers[0].id, offers[0].sdp, { - topics: offers[0].topics, - secret: 'my-secret-password' // Must match the offer's secret -}); -``` - -**Notes:** -- The actual secret is never exposed in public API responses - only a `hasSecret` boolean flag -- Answerers must provide the correct secret, or the answer will be rejected -- Secrets are limited to 128 characters -- Use this for access control, not for cryptographic security (use end-to-end encryption for that) - -## Connection Lifecycle - -The `RondevuPeer` uses a state machine for connection management: - -### Offerer States -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 - -### Answerer States -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 - -### State Events - -```typescript -peer.on('state', (stateName) => { - console.log('Current state:', stateName); -}); +channel.onopen = () => { + console.log('✅ Connected!'); + channel.send('Hello Alice!'); +}; peer.on('connected', () => { - // Connection established successfully -}); - -peer.on('disconnected', () => { - // Connection lost or closed + console.log('🎉 WebRTC connection established'); }); peer.on('failed', (error) => { - // Connection failed - console.error('Connection error:', error); + console.error('❌ Connection failed:', error); }); -peer.on('datachannel', (channel) => { - // Data channel is ready (use channel.addEventListener) -}); +// Option 2: List services first, then connect +const services = await client.discovery.listServices('alice'); +console.log(`Found ${services.services.length} services`); -peer.on('track', (event) => { - // Media track received (for audio/video streaming) - const stream = event.streams[0]; - videoElement.srcObject = stream; -}); +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 +); ``` -## Trickle ICE +## API Reference -This library implements **trickle ICE** for faster connection establishment: - -- ICE candidates are sent to the server as they're discovered -- No waiting for all candidates before sending offer/answer -- Connections establish much faster (milliseconds vs seconds) -- Proper event listener cleanup to prevent memory leaks - -## Adding Media Tracks +### Main Client ```typescript -// Get user's camera/microphone -const stream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true +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 }); -// Add tracks to peer connection -stream.getTracks().forEach(track => { - peer.addTrack(track, stream); +// 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); ``` -## Peer Properties +#### 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 -// Get current state name -console.log(peer.stateName); // 'idle', 'creating-offer', 'connected', etc. +// 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}`); -// Get connection state -console.log(peer.connectionState); // RTCPeerConnectionState + channel.onmessage = (e) => { + console.log(`📥 [${connectionId}] Received:`, e.data); + channel.send(`Echo: ${e.data}`); + }; -// Get offer ID (after creating offer or answering) -console.log(peer.offerId); + 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); + } +}); -// Get role -console.log(peer.role); // 'offerer' or 'answerer' +// 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); ``` -## Closing a Connection +**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 -await peer.close(); +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 +} ``` -## Custom RTCConfiguration +**Pooled Service Handle:** +```typescript +interface PooledServiceHandle extends ServiceHandle { + getStatus: () => PoolStatus; // Get current pool status + addOffers: (count: number) => Promise; // 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 +// 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 +}); + +// 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' }, @@ -278,56 +384,114 @@ const peer = client.createPeer({ credential: 'pass' } ], - iceTransportPolicy: 'relay' // Force TURN relay (useful for testing) + iceTransportPolicy: 'relay' // optional: force TURN relay }); -``` -## Timeouts +// Event listeners +peer.on('state', (state) => { + console.log('Peer state:', state); +}); -Configure connection timeouts: +peer.on('connected', () => { + console.log('✅ Connected'); +}); -```typescript -await peer.createOffer({ - topics: ['my-topic'], - timeouts: { - iceGathering: 10000, // ICE gathering timeout (10s) - waitingForAnswer: 30000, // Waiting for answer timeout (30s) - creatingAnswer: 10000, // Creating answer timeout (10s) - iceConnection: 30000 // ICE connection timeout (30s) +peer.on('disconnected', () => { + console.log('🔌 Disconnected'); +}); + +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; +}); + +// Create offer +const offerId = await peer.createOffer({ + ttl: 300000, // optional + timeouts: { // optional + iceGathering: 10000, + waitingForAnswer: 30000, + creatingAnswer: 10000, + iceConnection: 30000 } }); + +// Answer offer +await peer.answer(offerId, sdp); + +// Add media tracks +const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); +stream.getTracks().forEach(track => { + 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 Lifecycle + +### 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 + +### 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 fetch from 'node-fetch'; +import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'; const client = new Rondevu({ baseUrl: 'https://api.ronde.vu', - fetch: fetch as any + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate }); ``` -### Node.js with WebRTC (wrtc) - -For WebRTC functionality in Node.js, you need to provide WebRTC polyfills since Node.js doesn't have native WebRTC support: +### Node.js < 18 +Install both fetch and WebRTC polyfills: ```bash -npm install wrtc node-fetch +npm install node-fetch wrtc ``` ```typescript @@ -342,25 +506,9 @@ const client = new Rondevu({ RTCSessionDescription, RTCIceCandidate }); - -// Now you can use WebRTC features -await client.register(); -const peer = client.createPeer({ - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' } - ] -}); - -// Create offers, answer, etc. -const offerId = await peer.createOffer({ - topics: ['my-topic'] -}); ``` -**Note:** The `wrtc` package provides WebRTC bindings for Node.js. Alternative packages like `node-webrtc` can also be used - just pass their implementations to the Rondevu constructor. - ### Deno - ```typescript import { Rondevu } from 'npm:@xtr-dev/rondevu-client'; @@ -370,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'; @@ -390,181 +536,114 @@ export default { }; ``` -## Low-Level API Usage +## Examples -For direct control over the signaling process without WebRTC: +### 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 - secret: 'my-secret-password', // Optional: protect offer (max 128 chars) - info: 'Looking for peers in EU region' // Optional: public info (max 128 chars) -}]); - -// 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. - -Generates a cryptographically random 128-bit peer ID. - -```typescript -const creds = await client.register(); -// { peerId: 'f17c195f067255e357232e34cf0735d9', 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 - secret: 'my-secret-password', // optional, max 128 chars - info: 'Looking for peers in EU region' // optional, public info, max 128 chars +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.delete(offerId)` -Delete a specific offer. +### Video Chat Service ```typescript -await client.offers.delete(offerId); -``` +// Publisher +const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); -#### `client.offers.answer(offerId, sdp, secret?)` -Answer an offer (locks it to answerer). +const peer = client.createPeer(); +stream.getTracks().forEach(track => peer.addTrack(track, stream)); -```typescript -await client.offers.answer(offerId, answerSdp, 'my-secret-password'); -``` +const offerId = await peer.createOffer({ ttl: 300000 }); -**Parameters:** -- `offerId`: The offer ID to answer -- `sdp`: The WebRTC answer SDP -- `secret` (optional): Required if the offer has `hasSecret: true` +await client.services.publishService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'com.example.videochat@1.0.0', + isPublic: true +}); -#### `client.offers.getAnswers()` -Poll for answers to your offers. +// Consumer +const { peer, channel } = await client.discovery.connect( + 'alice', + 'com.example.videochat@1.0.0' +); -```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: 'candidate:1 1 UDP...', sdpMid: '0', sdpMLineIndex: 0 } -]); -``` - -#### `client.offers.getIceCandidates(offerId, since?)` -Get ICE candidates from the other peer. - -```typescript -const candidates = await client.offers.getIceCandidates(offerId, since); -``` - -### 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 @@ -574,54 +653,40 @@ All types are exported: ```typescript import type { Credentials, - Offer, - CreateOfferRequest, - TopicInfo, - IceCandidate, - FetchFunction, RondevuOptions, + + // 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 | Native WebRTC | Polyfills Needed | -|-------------|--------------|---------------|------------------| -| Modern Browsers | ✅ Yes | ✅ Yes | ❌ None | -| Node.js 18+ | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) | -| Node.js < 18 | ❌ No | ❌ No | ✅ Fetch + WebRTC | -| Deno | ✅ Yes | ⚠️ Partial | ❌ None (signaling only) | -| Bun | ✅ Yes | ❌ No | ✅ WebRTC (wrtc) | -| Cloudflare Workers | ✅ Yes | ❌ No | ❌ None (signaling only) | - -**For signaling-only (no WebRTC peer connections):** - -Use the low-level API with `client.offers` - no WebRTC polyfills needed. - -**For full WebRTC support in Node.js:** - -```bash -npm install wrtc node-fetch -``` - -```typescript -import { Rondevu } from '@xtr-dev/rondevu-client'; -import fetch from 'node-fetch'; -import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'; - -const client = new Rondevu({ - baseUrl: 'https://api.ronde.vu', - fetch: fetch as any, - RTCPeerConnection, - RTCSessionDescription, - RTCIceCandidate -}); -``` +**Key Changes:** +- ❌ Removed: `offers.findByTopic()`, `offers.getTopics()`, bloom filters +- ✅ Added: `usernames.*`, `services.*`, `discovery.*` APIs +- ✅ Changed: Focus on service-based discovery instead of topics ## License diff --git a/package-lock.json b/package-lock.json index 0014948..b5ff264f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,30 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.7.11", + "version": "0.7.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xtr-dev/rondevu-client", - "version": "0.7.11", + "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", diff --git a/package.json b/package.json index cfb213a..0f6689f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.7.12", - "description": "TypeScript client for Rondevu topic-based peer discovery and signaling server", + "version": "0.8.0", + "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" } } diff --git a/src/bloom.ts b/src/bloom.ts deleted file mode 100644 index 8800255..0000000 --- a/src/bloom.ts +++ /dev/null @@ -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; - } -} diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..c27daab --- /dev/null +++ b/src/discovery.ts @@ -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; +} + +/** + * 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; + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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((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 { + // 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((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 }; + } +} diff --git a/src/index.ts b/src/index.ts index b1918a6..3f48555 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/offer-pool.ts b/src/offer-pool.ts new file mode 100644 index 0000000..3e5c1c1 --- /dev/null +++ b/src/offer-pool.ts @@ -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; + + /** Callback to create new offers when refilling the pool */ + onRefill: (count: number) => Promise; + + /** 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 = new Map(); + private polling: boolean = false; + private pollingTimer?: ReturnType; + 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 { + for (const offer of offers) { + this.offers.set(offer.id, offer); + } + } + + /** + * Start polling for answers + */ + async start(): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/rondevu.ts b/src/rondevu.ts index 6f30a93..d2660ef 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -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 { @@ -65,7 +68,11 @@ export interface RondevuOptions { 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; @@ -81,15 +88,19 @@ export class Rondevu { this.rtcIceCandidate = options.RTCIceCandidate; this.auth = new RondevuAuth(this.baseUrl, this.fetchFn); + this.usernames = new RondevuUsername(this.baseUrl); if (options.credentials) { this.credentials = options.credentials; 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) { @@ -98,6 +109,26 @@ 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) @@ -105,12 +136,14 @@ export class Rondevu { async register(): Promise { 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; } diff --git a/src/service-pool.ts b/src/service-pool.ts new file mode 100644 index 0000000..7c3c136 --- /dev/null +++ b/src/service-pool.ts @@ -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; + + /** 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; +} + +/** + * 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 = 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 { + 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 { + // 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 { + 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((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 { + 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 { + 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)}`; + } +} diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..02c5644 --- /dev/null +++ b/src/services.ts @@ -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; + ttl?: number; + onConnection?: (peer: RondevuPeer) => void; +} + +/** + * Service handle for managing an exposed service + */ +export interface ServiceHandle { + serviceId: string; + uuid: string; + offerId: string; + unpublish: () => Promise; +} + +/** + * 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 { + 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 { + 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 & { + handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId?: string) => void; + poolSize?: number; + pollingInterval?: number; + onPoolStatus?: (status: PoolStatus) => void; + onError?: (error: Error, context: string) => void; + }): Promise { + 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] }; + } +} diff --git a/src/usernames.ts b/src/usernames.ts new file mode 100644 index 0000000..5a8bbd6 --- /dev/null +++ b/src/usernames.ts @@ -0,0 +1,193 @@ +import * as ed25519 from '@noble/ed25519'; + +/** + * 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.getPublicKey(privateKey); + + return { + publicKey: bytesToBase64(publicKey), + privateKey: bytesToBase64(privateKey) + }; + } + + /** + * Signs a message with an Ed25519 private key + */ + async signMessage(message: string, privateKeyBase64: string): Promise { + const privateKey = base64ToBytes(privateKeyBase64); + const encoder = new TextEncoder(); + const messageBytes = encoder.encode(message); + + const signature = await ed25519.sign(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 { + // 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 { + 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 }; + } +}