diff --git a/README.md b/README.md index 2049c88..11e0e1e 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,13 @@ npm install @xtr-dev/rondevu-client ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' -// 1. Initialize (with username or anonymous) -const rondevu = new Rondevu({ +// 1. Connect to Rondevu (generates keypair, username auto-claimed on first request) +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice' // Or omit for anonymous username }) -await rondevu.initialize() // Generates keypair automatically - -// 2. Claim username (optional - anonymous users auto-claim) -await rondevu.claimUsername() - -// 3. Create WebRTC offer +// 2. Create WebRTC offer const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) @@ -61,7 +56,7 @@ await pc.setLocalDescription(offer) // 4. Publish service const service = await rondevu.publishService({ - serviceFqn: 'chat:1.0.0@alice', + service: 'chat:1.0.0', offers: [{ sdp: offer.sdp }], ttl: 300000 }) @@ -120,15 +115,12 @@ dc.onopen = () => { ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' -// 1. Initialize -const rondevu = new Rondevu({ +// 1. Connect to Rondevu +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'bob' }) -await rondevu.initialize() -await rondevu.claimUsername() - // 2. Get service offer const serviceData = await rondevu.getService('chat:1.0.0@alice') @@ -201,11 +193,13 @@ Main class for all Rondevu operations. ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' -const rondevu = new Rondevu({ +// Create and connect to Rondevu +const rondevu = await Rondevu.connect({ apiUrl: string, // Signaling server URL username?: string, // Optional: your username (auto-generates anonymous if omitted) keypair?: Keypair, // Optional: reuse existing keypair cryptoAdapter?: CryptoAdapter // Optional: platform-specific crypto (defaults to WebCryptoAdapter) + batching?: BatcherOptions | false // Optional: RPC batching configuration }) ``` @@ -218,7 +212,7 @@ The client supports both browser and Node.js environments using crypto adapters: import { Rondevu } from '@xtr-dev/rondevu-client' // WebCryptoAdapter is used by default - no configuration needed -const rondevu = new Rondevu({ +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice' }) @@ -228,13 +222,11 @@ const rondevu = new Rondevu({ ```typescript import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client' -const rondevu = new Rondevu({ +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice', cryptoAdapter: new NodeCryptoAdapter() }) - -await rondevu.initialize() ``` **Note:** Node.js support requires: @@ -255,25 +247,17 @@ class CustomCryptoAdapter implements CryptoAdapter { randomBytes(length: number): Uint8Array { /* ... */ } } -const rondevu = new Rondevu({ +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', cryptoAdapter: new CustomCryptoAdapter() }) ``` -#### Initialization - -```typescript -// Initialize (generates keypair if not provided, auto-claims anonymous usernames) -await rondevu.initialize(): Promise -``` - #### Username Management -```typescript -// Claim username with Ed25519 signature -await rondevu.claimUsername(): Promise +Usernames are **automatically claimed** on the first authenticated request (like `publishService()`). +```typescript // Check if username is claimed (checks server) await rondevu.isUsernameClaimed(): Promise @@ -281,10 +265,10 @@ await rondevu.isUsernameClaimed(): Promise rondevu.getUsername(): string // Get public key -rondevu.getPublicKey(): string | null +rondevu.getPublicKey(): string // Get keypair (for backup/storage) -rondevu.getKeypair(): Keypair | null +rondevu.getKeypair(): Keypair ``` #### Service Publishing @@ -292,9 +276,9 @@ rondevu.getKeypair(): Keypair | null ```typescript // Publish service with offers await rondevu.publishService({ - serviceFqn: string, // e.g., 'chat:1.0.0@alice' + service: string, // e.g., 'chat:1.0.0' (username auto-appended) offers: Array<{ sdp: string }>, - ttl?: number // Optional: milliseconds (default: 300000) + ttl?: number // Optional: milliseconds (default: 300000) }): Promise ``` @@ -452,13 +436,8 @@ await api.checkUsername(username: string): Promise<{ expiresAt?: number }> -// Claim username -await api.claimUsername( - username: string, - publicKey: string, - signature: string, - message: string -): Promise<{ success: boolean, username: string }> +// Note: Username claiming is now implicit - usernames are auto-claimed +// on first authenticated request to the server // ... (all other HTTP endpoints) ``` @@ -527,18 +506,16 @@ interface PollingConfig { ```typescript // Auto-generate anonymous username (format: anon-{timestamp}-{random}) -const rondevu = new Rondevu({ +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu' // No username provided - will generate anonymous username }) -await rondevu.initialize() // Auto-claims anonymous username - console.log(rondevu.getUsername()) // e.g., "anon-lx2w34-a3f501" // Anonymous users behave exactly like regular users await rondevu.publishService({ - serviceFqn: `chat:1.0.0@${rondevu.getUsername()}`, + service: 'chat:1.0.0', offers: [{ sdp: offerSdp }] }) ``` @@ -547,15 +524,12 @@ await rondevu.publishService({ ```typescript // Save keypair and username to localStorage -const rondevu = new Rondevu({ +const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice' }) -await rondevu.initialize() -await rondevu.claimUsername() - -// Save for later +// Save for later (username will be auto-claimed on first authenticated request) localStorage.setItem('rondevu-username', rondevu.getUsername()) localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair())) @@ -563,13 +537,11 @@ localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair())) const savedUsername = localStorage.getItem('rondevu-username') const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair')) -const rondevu2 = new Rondevu({ +const rondevu2 = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: savedUsername, keypair: savedKeypair }) - -await rondevu2.initialize() // Reuses keypair ``` ### Service Discovery @@ -603,7 +575,7 @@ for (let i = 0; i < 5; i++) { } const service = await rondevu.publishService({ - serviceFqn: 'chat:1.0.0@alice', + service: 'chat:1.0.0', offers, ttl: 300000 }) @@ -666,7 +638,45 @@ const pc = new RTCPeerConnection() ## Examples -See the [demo](https://github.com/xtr-dev/rondevu-demo) for a complete working example with React UI. +### Node.js Service Host Example + +You can host WebRTC services in Node.js that browser clients can connect to. See the [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) for a complete guide. + +**Quick example:** + +```typescript +import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client' +import wrtc from 'wrtc' + +const { RTCPeerConnection } = wrtc + +// Initialize with Node crypto adapter +const rondevu = await Rondevu.connect({ + apiUrl: 'https://api.ronde.vu', + username: 'mybot', + cryptoAdapter: new NodeCryptoAdapter() +}) + +// Create peer connection (offerer creates data channel) +const pc = new RTCPeerConnection(rtcConfig) +const dc = pc.createDataChannel('chat') + +// Publish service (username auto-claimed on first publish) +const offer = await pc.createOffer() +await pc.setLocalDescription(offer) + +await rondevu.publishService({ + service: 'chat:1.0.0', + offers: [{ sdp: offer.sdp }] +}) + +// Browser clients can now discover and connect to chat:1.0.0@mybot +``` + +See complete examples: +- [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) - Full guide with complete examples +- [test-connect.js](../demo/test-connect.js) - Working Node.js client example +- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Complete browser UI ([live](https://ronde.vu)) ## Migration from v0.3.x diff --git a/src/api.ts b/src/api.ts index 583ad3e..8b73cc6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -157,7 +157,21 @@ export class RondevuAPI { const results: RpcResponse[] = await response.json() + // Validate response is an array + if (!Array.isArray(results)) { + console.error('Invalid RPC batch response:', results) + throw new Error('Server returned invalid batch response (not an array)') + } + + // Check response length matches request length + if (results.length !== requests.length) { + console.error(`Response length mismatch: expected ${requests.length}, got ${results.length}`) + } + return results.map((result, i) => { + if (!result || typeof result !== 'object') { + throw new Error(`Invalid response at index ${i}`) + } if (!result.success) { throw new Error(result.error || `RPC call ${i} failed`) } @@ -223,20 +237,6 @@ export class RondevuAPI { return result.available } - /** - * Claim a username - */ - async claimUsername(username: string, publicKey: string): Promise { - const auth = await this.generateAuth('claim', username) - await this.rpc({ - method: 'claimUsername', - message: auth.message, - signature: auth.signature, - publicKey: this.keypair.publicKey, - params: { username, publicKey }, - }) - } - /** * Check if current username is claimed */ diff --git a/src/rondevu.ts b/src/rondevu.ts index 45db92c..fa07e55 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -10,7 +10,7 @@ export interface RondevuOptions { } export interface PublishServiceOptions { - serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice") + service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended offers: Array<{ sdp: string }> ttl?: number } @@ -19,7 +19,7 @@ export interface PublishServiceOptions { * Rondevu - Complete WebRTC signaling client * * Provides a unified API for: - * - Username claiming with Ed25519 signatures + * - Implicit username claiming (auto-claimed on first authenticated request) * - Service publishing with automatic signature generation * - Service discovery (direct, random, paginated) * - WebRTC signaling (offer/answer exchange, ICE relay) @@ -27,17 +27,15 @@ export interface PublishServiceOptions { * * @example * ```typescript - * // Create Rondevu instance with username - * const rondevu = new Rondevu({ + * // Create and initialize Rondevu instance + * const rondevu = await Rondevu.connect({ * apiUrl: 'https://signal.example.com', * username: 'alice', * }) - * await rondevu.initialize() - * await rondevu.claimUsername() // Claim username before publishing * - * // Publish a service + * // Publish a service (username auto-claimed on first publish) * const publishedService = await rondevu.publishService({ - * serviceFqn: 'chat:1.0.0@alice', + * service: 'chat:1.0.0', * offers: [{ sdp: offerSdp }], * ttl: 300000, * }) @@ -50,120 +48,106 @@ export interface PublishServiceOptions { * ``` */ export class Rondevu { - private api: RondevuAPI | null = null + private api: RondevuAPI private readonly apiUrl: string private username: string - private keypair: Keypair | null = null + private keypair: Keypair private usernameClaimed = false private cryptoAdapter?: CryptoAdapter private batchingOptions?: BatcherOptions | false - constructor(options: RondevuOptions) { - this.apiUrl = options.apiUrl - this.username = options.username || this.generateAnonymousUsername() - this.keypair = options.keypair || null - this.cryptoAdapter = options.cryptoAdapter - this.batchingOptions = options.batching + private constructor( + apiUrl: string, + username: string, + keypair: Keypair, + api: RondevuAPI, + cryptoAdapter?: CryptoAdapter, + batchingOptions?: BatcherOptions | false + ) { + this.apiUrl = apiUrl + this.username = username + this.keypair = keypair + this.api = api + this.cryptoAdapter = cryptoAdapter + this.batchingOptions = batchingOptions - console.log('[Rondevu] Constructor called:', { + console.log('[Rondevu] Instance created:', { username: this.username, - hasKeypair: !!this.keypair, - publicKey: this.keypair?.publicKey, + publicKey: this.keypair.publicKey, + batchingEnabled: batchingOptions !== false + }) + } + + /** + * Create and initialize a Rondevu client + * + * @example + * ```typescript + * const rondevu = await Rondevu.connect({ + * apiUrl: 'https://api.ronde.vu', + * username: 'alice' + * }) + * ``` + */ + static async connect(options: RondevuOptions): Promise { + const username = options.username || Rondevu.generateAnonymousUsername() + + console.log('[Rondevu] Connecting:', { + username, + hasKeypair: !!options.keypair, batchingEnabled: options.batching !== false }) + + // Generate keypair if not provided + let keypair = options.keypair + if (!keypair) { + console.log('[Rondevu] Generating new keypair...') + keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter) + console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey) + } else { + console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey) + } + + // Create API instance + const api = new RondevuAPI( + options.apiUrl, + username, + keypair, + options.cryptoAdapter, + options.batching + ) + console.log('[Rondevu] Created API instance') + + return new Rondevu( + options.apiUrl, + username, + keypair, + api, + options.cryptoAdapter, + options.batching + ) } /** * Generate an anonymous username with timestamp and random component */ - private generateAnonymousUsername(): string { + private static generateAnonymousUsername(): string { const timestamp = Date.now().toString(36) const random = Array.from(crypto.getRandomValues(new Uint8Array(3))) .map(b => b.toString(16).padStart(2, '0')).join('') return `anon-${timestamp}-${random}` } - // ============================================ - // Initialization - // ============================================ - - /** - * Initialize the service - generates keypair if not provided and creates API instance - * Call this before using other methods - */ - async initialize(): Promise { - console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair) - - // Generate keypair if not provided - if (!this.keypair) { - console.log('[Rondevu] Generating new keypair...') - this.keypair = await RondevuAPI.generateKeypair(this.cryptoAdapter) - console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey) - } else { - console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey) - } - - // Create API instance with username, keypair, crypto adapter, and batching options - this.api = new RondevuAPI( - this.apiUrl, - this.username, - this.keypair, - this.cryptoAdapter, - this.batchingOptions - ) - console.log('[Rondevu] Created API instance with username:', this.username) - } - // ============================================ // Username Management // ============================================ - /** - * Claim the username with Ed25519 signature - * Should be called once before publishing services - */ - async claimUsername(): Promise { - if (!this.keypair) { - throw new Error('Not initialized. Call initialize() first.') - } - - // Check if username is already claimed - const available = await this.getAPI().isUsernameAvailable(this.username) - if (!available) { - // Check if it's claimed by us - const claimed = await this.getAPI().isUsernameClaimed() - if (claimed) { - this.usernameClaimed = true - return - } - throw new Error(`Username "${this.username}" is already claimed by another user`) - } - - // Claim the username - await this.getAPI().claimUsername(this.username, this.keypair.publicKey) - this.usernameClaimed = true - } - - /** - * Get API instance (creates lazily if needed) - */ - private getAPI(): RondevuAPI { - if (!this.api) { - throw new Error('Not initialized. Call initialize() first.') - } - return this.api - } - /** * Check if username has been claimed (checks with server) */ async isUsernameClaimed(): Promise { - if (!this.keypair) { - return false - } - try { - const claimed = await this.getAPI().isUsernameClaimed() + const claimed = await this.api.isUsernameClaimed() // Update internal flag to match server state this.usernameClaimed = claimed @@ -184,15 +168,14 @@ export class Rondevu { * Username will be automatically claimed on first publish if not already claimed */ async publishService(options: PublishServiceOptions): Promise { - if (!this.keypair) { - throw new Error('Not initialized. Call initialize() first.') - } + const { service, offers, ttl } = options - const { serviceFqn, offers, ttl } = options + // Auto-append username to service + const serviceFqn = `${service}@${this.username}` // Publish to server (server will auto-claim username if needed) // Note: signature and message are generated by the API layer - const result = await this.getAPI().publishService({ + const result = await this.api.publishService({ serviceFqn, offers, ttl, @@ -223,7 +206,7 @@ export class Rondevu { createdAt: number expiresAt: number }> { - return await this.getAPI().getService(serviceFqn) + return await this.api.getService(serviceFqn) } /** @@ -239,7 +222,7 @@ export class Rondevu { createdAt: number expiresAt: number }> { - return await this.getAPI().getService(serviceVersion) + return await this.api.getService(serviceVersion) } /** @@ -260,7 +243,7 @@ export class Rondevu { limit: number offset: number }> { - return await this.getAPI().getService(serviceVersion, { limit, offset }) + return await this.api.getService(serviceVersion, { limit, offset }) } // ============================================ @@ -274,7 +257,7 @@ export class Rondevu { success: boolean offerId: string }> { - await this.getAPI().answerOffer(serviceFqn, offerId, sdp) + await this.api.answerOffer(serviceFqn, offerId, sdp) return { success: true, offerId } } @@ -287,7 +270,7 @@ export class Rondevu { answererId: string answeredAt: number } | null> { - return await this.getAPI().getOfferAnswer(serviceFqn, offerId) + return await this.api.getOfferAnswer(serviceFqn, offerId) } /** @@ -309,7 +292,7 @@ export class Rondevu { createdAt: number }>> }> { - return await this.getAPI().poll(since) + return await this.api.poll(since) } /** @@ -319,7 +302,7 @@ export class Rondevu { count: number offerId: string }> { - return await this.getAPI().addOfferIceCandidates(serviceFqn, offerId, candidates) + return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates) } /** @@ -329,7 +312,7 @@ export class Rondevu { candidates: IceCandidate[] offerId: string }> { - return await this.getAPI().getOfferIceCandidates(serviceFqn, offerId, since) + return await this.api.getOfferIceCandidates(serviceFqn, offerId, since) } // ============================================ @@ -339,7 +322,7 @@ export class Rondevu { /** * Get the current keypair (for backup/storage) */ - getKeypair(): Keypair | null { + getKeypair(): Keypair { return this.keypair } @@ -353,8 +336,8 @@ export class Rondevu { /** * Get the public key */ - getPublicKey(): string | null { - return this.keypair?.publicKey || null + getPublicKey(): string { + return this.keypair.publicKey } /** @@ -362,9 +345,6 @@ export class Rondevu { * @deprecated Use direct methods on Rondevu instance instead */ getAPIPublic(): RondevuAPI { - if (!this.api) { - throw new Error('Not initialized. Call initialize() first.') - } return this.api } }