From a499062e521226bd4e902e2740dcf23ca99fc397 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 12 Dec 2025 20:10:03 +0100 Subject: [PATCH] refactor: Update client to use RPC interface BREAKING CHANGES: - All API calls now go to POST /rpc endpoint - Request format: { method, message, signature, params } - Response format: { success, result } or { success: false, error } - Simplified API methods to match RPC methods - Removed checkUsername, added isUsernameAvailable - Renamed postOfferAnswer to answerOffer - Removed discoverService/discoverServices (use getService) Changes: - Completely refactored api.ts for RPC interface - Updated rondevu.ts wrapper methods - Updated rondevu-signaler.ts to use new API - Fixed exports in index.ts --- src/api.ts | 586 +++++++++++++++++----------------------- src/index.ts | 2 +- src/rondevu-signaler.ts | 3 +- src/rondevu.ts | 36 +-- 4 files changed, 262 insertions(+), 365 deletions(-) diff --git a/src/api.ts b/src/api.ts index ec63fe7..8c2640a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ /** - * Rondevu API Client - Single class for all API endpoints + * Rondevu API Client - RPC interface */ import * as ed25519 from '@noble/ed25519' @@ -14,24 +14,6 @@ export interface Keypair { privateKey: string } -export interface OfferRequest { - sdp: string - topics?: string[] - ttl?: number - secret?: string -} - -export interface Offer { - id: string - peerId: string - sdp: string - topics: string[] - ttl: number - createdAt: number - expiresAt: number - answererPeerId?: string -} - export interface OfferRequest { sdp: string } @@ -82,7 +64,26 @@ function base64ToBytes(base64: string): Uint8Array { } /** - * RondevuAPI - Complete API client for Rondevu signaling server + * RPC request format + */ +interface RpcRequest { + method: string + message: string + signature: string + params?: any +} + +/** + * RPC response format + */ +interface RpcResponse { + success: boolean + result?: any + error?: string +} + +/** + * RondevuAPI - RPC-based API client for Rondevu signaling server */ export class RondevuAPI { constructor( @@ -92,21 +93,67 @@ export class RondevuAPI { ) {} /** - * Generate authentication parameters (username, signature, message) for API calls + * Generate authentication parameters for RPC calls */ - private async generateAuthParams(action: string, params: string = ''): Promise<{ - username: string; - signature: string; - message: string; + private async generateAuth(method: string, params: string = ''): Promise<{ + message: string + signature: string }> { - const timestamp = Date.now(); + const timestamp = Date.now() const message = params - ? `${action}:${this.username}:${params}:${timestamp}` - : `${action}:${this.username}:${timestamp}`; + ? `${method}:${this.username}:${params}:${timestamp}` + : `${method}:${this.username}:${timestamp}` - const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey); + const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey) - return { username: this.username, signature, message }; + return { message, signature } + } + + /** + * Execute RPC call + */ + private async rpc(request: RpcRequest): Promise { + const response = await fetch(`${this.baseUrl}/rpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const result: RpcResponse = await response.json() + + if (!result.success) { + throw new Error(result.error || 'RPC call failed') + } + + return result.result + } + + /** + * Execute batch RPC calls + */ + private async rpcBatch(requests: RpcRequest[]): Promise { + const response = await fetch(`${this.baseUrl}/rpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requests), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const results: RpcResponse[] = await response.json() + + return results.map((result, i) => { + if (!result.success) { + throw new Error(result.error || `RPC call ${i} failed`) + } + return result.result + }) } // ============================================ @@ -133,370 +180,235 @@ export class RondevuAPI { const privateKey = base64ToBytes(privateKeyBase64) const encoder = new TextEncoder() const messageBytes = encoder.encode(message) - const signature = await ed25519.signAsync(messageBytes, privateKey) + return bytesToBase64(signature) } /** - * Verify a signature + * Verify an Ed25519 signature */ static async verifySignature( message: string, signatureBase64: string, publicKeyBase64: string ): Promise { - const publicKey = base64ToBytes(publicKeyBase64) - const signature = base64ToBytes(signatureBase64) - const encoder = new TextEncoder() - const messageBytes = encoder.encode(message) + try { + const signature = base64ToBytes(signatureBase64) + const publicKey = base64ToBytes(publicKeyBase64) + const encoder = new TextEncoder() + const messageBytes = encoder.encode(message) - return await ed25519.verifyAsync(signature, messageBytes, publicKey) + return await ed25519.verifyAsync(signature, messageBytes, publicKey) + } catch { + return false + } } // ============================================ - // Offers + // Username Management // ============================================ /** - * Create one or more offers + * Check if a username is available */ - async createOffers(offers: OfferRequest[]): Promise { - const auth = await this.generateAuthParams('createOffers'); - - const response = await fetch(`${this.baseUrl}/offers`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ offers, ...auth }), + async isUsernameAvailable(username: string): Promise { + const auth = await this.generateAuth('getUser', username) + const result = await this.rpc({ + method: 'getUser', + message: auth.message, + signature: auth.signature, + params: { username }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to create offers: ${error.error || response.statusText}`) - } - - return await response.json() + return result.available } /** - * Get offer by ID + * Claim a username */ - async getOffer(offerId: string): Promise { - const auth = await this.generateAuthParams('getOffer', offerId); - const url = new URL(`${this.baseUrl}/offers/${offerId}`); - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to get offer: ${error.error || response.statusText}`) - } - - return await response.json() - } - - /** - * Answer a specific offer from a service - */ - async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> { - const auth = await this.generateAuthParams('answerOffer', `${serviceFqn}:${offerId}`); - - const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ sdp, ...auth }), + 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, + params: { username, publicKey }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to answer offer: ${error.error || response.statusText}`) - } - - return await response.json() } /** - * Combined polling for answers and ICE candidates - * Returns all answered offers and ICE candidates since timestamp + * Check if current username is claimed */ - async poll(since?: number): Promise<{ - answers: Array<{ - offerId: string; - serviceId?: string; - answererId: string; - sdp: string; - answeredAt: number; - }>; - iceCandidates: Record>; - }> { - const auth = await this.generateAuthParams('poll', since?.toString() || ''); - const url = new URL(`${this.baseUrl}/poll`); + async isUsernameClaimed(): Promise { + const auth = await this.generateAuth('getUser', this.username) + const result = await this.rpc({ + method: 'getUser', + message: auth.message, + signature: auth.signature, + params: { username: this.username }, + }) + return !result.available + } - if (since) { - url.searchParams.set('since', since.toString()); - } - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); + // ============================================ + // Service Management + // ============================================ - const response = await fetch(url.toString()) + /** + * Publish a service + */ + async publishService(service: ServiceRequest): Promise { + const auth = await this.generateAuth('publishService', service.serviceFqn) + return await this.rpc({ + method: 'publishService', + message: auth.message, + signature: auth.signature, + params: { + serviceFqn: service.serviceFqn, + offers: service.offers, + ttl: service.ttl, + }, + }) + } - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to poll: ${error.error || response.statusText}`) - } + /** + * Get service by FQN (direct lookup, random, or paginated) + */ + async getService( + serviceFqn: string, + options?: { limit?: number; offset?: number } + ): Promise { + const auth = await this.generateAuth('getService', serviceFqn) + return await this.rpc({ + method: 'getService', + message: auth.message, + signature: auth.signature, + params: { + serviceFqn, + ...options, + }, + }) + } - return await response.json() + /** + * Delete a service + */ + async deleteService(serviceFqn: string): Promise { + const auth = await this.generateAuth('deleteService', serviceFqn) + await this.rpc({ + method: 'deleteService', + message: auth.message, + signature: auth.signature, + params: { serviceFqn }, + }) + } + + // ============================================ + // WebRTC Signaling + // ============================================ + + /** + * Answer an offer + */ + async answerOffer(serviceFqn: string, offerId: string, sdp: string): Promise { + const auth = await this.generateAuth('answerOffer', offerId) + await this.rpc({ + method: 'answerOffer', + message: auth.message, + signature: auth.signature, + params: { serviceFqn, offerId, sdp }, + }) } /** * Get answer for a specific offer (offerer polls this) */ - async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> { - const auth = await this.generateAuthParams('getOfferAnswer', `${serviceFqn}:${offerId}`); - const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`); - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - // 404 means not yet answered - if (response.status === 404) { + async getOfferAnswer( + serviceFqn: string, + offerId: string + ): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> { + try { + const auth = await this.generateAuth('getOfferAnswer', offerId) + return await this.rpc({ + method: 'getOfferAnswer', + message: auth.message, + signature: auth.signature, + params: { serviceFqn, offerId }, + }) + } catch (err) { + if ((err as Error).message.includes('not yet answered')) { return null } - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to get answer: ${error.error || response.statusText}`) + throw err } - - return await response.json() } /** - * Search offers by topic + * Combined polling for answers and ICE candidates */ - async searchOffers(topic: string): Promise { - const auth = await this.generateAuthParams('searchOffers', topic); - const url = new URL(`${this.baseUrl}/offers`); - url.searchParams.set('topic', topic); - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to search offers: ${error.error || response.statusText}`) - } - - return await response.json() + async poll(since?: number): Promise<{ + answers: Array<{ + offerId: string + serviceId?: string + answererId: string + sdp: string + answeredAt: number + }> + iceCandidates: Record< + string, + Array<{ + candidate: any + role: 'offerer' | 'answerer' + peerId: string + createdAt: number + }> + > + }> { + const auth = await this.generateAuth('poll') + return await this.rpc({ + method: 'poll', + message: auth.message, + signature: auth.signature, + params: { since }, + }) } - // ============================================ - // ICE Candidates - // ============================================ - /** * Add ICE candidates to a specific offer */ - async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> { - const auth = await this.generateAuthParams('addIceCandidates', `${serviceFqn}:${offerId}`); - - const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ candidates, ...auth }), + async addOfferIceCandidates( + serviceFqn: string, + offerId: string, + candidates: RTCIceCandidateInit[] + ): Promise<{ count: number; offerId: string }> { + const auth = await this.generateAuth('addIceCandidates', offerId) + return await this.rpc({ + method: 'addIceCandidates', + message: auth.message, + signature: auth.signature, + params: { serviceFqn, offerId, candidates }, }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`) - } - - return await response.json() } /** - * Get ICE candidates for a specific offer (with polling support) + * Get ICE candidates for a specific offer */ - async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> { - const auth = await this.generateAuthParams('getIceCandidates', `${serviceFqn}:${offerId}:${since}`); - const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`) - url.searchParams.set('since', since.toString()) - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); + async getOfferIceCandidates( + serviceFqn: string, + offerId: string, + since: number = 0 + ): Promise<{ candidates: IceCandidate[]; offerId: string }> { + const auth = await this.generateAuth('getIceCandidates', `${offerId}:${since}`) + const result = await this.rpc({ + method: 'getIceCandidates', + message: auth.message, + signature: auth.signature, + params: { serviceFqn, offerId, since }, + }) - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`) - } - - const data = await response.json() return { - candidates: data.candidates || [], - offerId: data.offerId + candidates: result.candidates || [], + offerId: result.offerId, } } - - // ============================================ - // Services - // ============================================ - - /** - * Publish a service - * Service FQN must include username: service:version@username - */ - async publishService(service: ServiceRequest): Promise { - const response = await fetch(`${this.baseUrl}/services`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...service, - username: this.username - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to publish service: ${error.error || response.statusText}`) - } - - return await response.json() - } - - /** - * Get service by FQN (with username) - Direct lookup - * Example: chat:1.0.0@alice - */ - async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> { - const auth = await this.generateAuthParams('getService', serviceFqn); - const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`); - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to get service: ${error.error || response.statusText}`) - } - - return await response.json() - } - - /** - * Discover a random available service without knowing the username - * Example: chat:1.0.0 (without @username) - */ - async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> { - const auth = await this.generateAuthParams('discoverService', serviceVersion); - const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`); - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to discover service: ${error.error || response.statusText}`) - } - - return await response.json() - } - - /** - * Discover multiple available services with pagination - * Example: chat:1.0.0 (without @username) - */ - async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> { - const auth = await this.generateAuthParams('discoverServices', `${serviceVersion}:${limit}:${offset}`); - const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`) - url.searchParams.set('limit', limit.toString()) - url.searchParams.set('offset', offset.toString()) - url.searchParams.set('username', auth.username); - url.searchParams.set('signature', auth.signature); - url.searchParams.set('message', auth.message); - - const response = await fetch(url.toString()) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to discover services: ${error.error || response.statusText}`) - } - - return await response.json() - } - - - // ============================================ - // Usernames - // ============================================ - - /** - * Check if username is available - */ - async checkUsername(username: string): Promise<{ available: boolean; publicKey?: string; claimedAt?: number; expiresAt?: number }> { - const response = await fetch( - `${this.baseUrl}/users/${encodeURIComponent(username)}` - ) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to check username: ${error.error || response.statusText}`) - } - - return await response.json() - } - - /** - * Claim a username (requires Ed25519 signature) - */ - async claimUsername( - username: string, - publicKey: string, - signature: string, - message: string - ): Promise<{ success: boolean; username: string }> { - const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(username)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - publicKey, - signature, - message, - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(`Failed to claim username: ${error.error || response.statusText}`) - } - - return await response.json() - } } diff --git a/src/index.ts b/src/index.ts index dc23fc3..115803f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,9 +16,9 @@ export type { export type { Keypair, OfferRequest, - Offer, ServiceRequest, Service, + ServiceOffer, IceCandidate, } from './api.js' diff --git a/src/rondevu-signaler.ts b/src/rondevu-signaler.ts index f0db5f7..d193741 100644 --- a/src/rondevu-signaler.ts +++ b/src/rondevu-signaler.ts @@ -111,8 +111,7 @@ export class RondevuSignaler implements Signaler { } // Send answer to the service - const result = await this.rondevu.getAPIPublic().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp) - this.offerId = result.offerId + await this.rondevu.getAPIPublic().answerOffer(this.serviceFqn, this.offerId, answer.sdp) this.isOfferer = false // Start polling for ICE candidates (answerer uses separate endpoint) diff --git a/src/rondevu.ts b/src/rondevu.ts index 3d35d86..4a34d1a 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -114,22 +114,19 @@ export class Rondevu { } // Check if username is already claimed - const check = await this.getAPI().checkUsername(this.username) - if (!check.available) { - // Verify it's claimed by us - if (check.publicKey === this.keypair.publicKey) { + 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`) } - // Generate signature for username claim - const message = `claim:${this.username}:${Date.now()}` - const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey) - // Claim the username - await this.getAPI().claimUsername(this.username, this.keypair.publicKey, signature, message) + await this.getAPI().claimUsername(this.username, this.keypair.publicKey) this.usernameClaimed = true } @@ -152,19 +149,7 @@ export class Rondevu { } try { - const check = await this.getAPI().checkUsername(this.username) - - // Debug logging - console.log('[Rondevu] Username check:', { - username: this.username, - available: check.available, - serverPublicKey: check.publicKey, - localPublicKey: this.keypair.publicKey, - match: check.publicKey === this.keypair.publicKey - }) - - // Username is claimed if it's not available and owned by our public key - const claimed = !check.available && check.publicKey === this.keypair.publicKey + const claimed = await this.getAPI().isUsernameClaimed() // Update internal flag to match server state this.usernameClaimed = claimed @@ -246,7 +231,7 @@ export class Rondevu { createdAt: number expiresAt: number }> { - return await this.getAPI().discoverService(serviceVersion) + return await this.getAPI().getService(serviceVersion) } /** @@ -267,7 +252,7 @@ export class Rondevu { limit: number offset: number }> { - return await this.getAPI().discoverServices(serviceVersion, limit, offset) + return await this.getAPI().getService(serviceVersion, { limit, offset }) } // ============================================ @@ -281,7 +266,8 @@ export class Rondevu { success: boolean offerId: string }> { - return await this.getAPI().postOfferAnswer(serviceFqn, offerId, sdp) + await this.getAPI().answerOffer(serviceFqn, offerId, sdp) + return { success: true, offerId } } /**