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
This commit is contained in:
2025-12-12 20:10:03 +01:00
parent b5f36d8f77
commit a499062e52
4 changed files with 262 additions and 365 deletions

View File

@@ -1,5 +1,5 @@
/** /**
* Rondevu API Client - Single class for all API endpoints * Rondevu API Client - RPC interface
*/ */
import * as ed25519 from '@noble/ed25519' import * as ed25519 from '@noble/ed25519'
@@ -14,24 +14,6 @@ export interface Keypair {
privateKey: string 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 { export interface OfferRequest {
sdp: string 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 { export class RondevuAPI {
constructor( 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<{ private async generateAuth(method: string, params: string = ''): Promise<{
username: string; message: string
signature: string; signature: string
message: string;
}> { }> {
const timestamp = Date.now(); const timestamp = Date.now()
const message = params const message = params
? `${action}:${this.username}:${params}:${timestamp}` ? `${method}:${this.username}:${params}:${timestamp}`
: `${action}:${this.username}:${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<any> {
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<any[]> {
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 privateKey = base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder() const encoder = new TextEncoder()
const messageBytes = encoder.encode(message) const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey) const signature = await ed25519.signAsync(messageBytes, privateKey)
return bytesToBase64(signature) return bytesToBase64(signature)
} }
/** /**
* Verify a signature * Verify an Ed25519 signature
*/ */
static async verifySignature( static async verifySignature(
message: string, message: string,
signatureBase64: string, signatureBase64: string,
publicKeyBase64: string publicKeyBase64: string
): Promise<boolean> { ): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64) try {
const signature = base64ToBytes(signatureBase64) const signature = base64ToBytes(signatureBase64)
const publicKey = base64ToBytes(publicKeyBase64)
const encoder = new TextEncoder() const encoder = new TextEncoder()
const messageBytes = encoder.encode(message) 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<Offer[]> { async isUsernameAvailable(username: string): Promise<boolean> {
const auth = await this.generateAuthParams('createOffers'); const auth = await this.generateAuth('getUser', username)
const result = await this.rpc({
const response = await fetch(`${this.baseUrl}/offers`, { method: 'getUser',
method: 'POST', message: auth.message,
headers: { signature: auth.signature,
'Content-Type': 'application/json', params: { username },
},
body: JSON.stringify({ offers, ...auth }),
}) })
return result.available
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()
} }
/** /**
* Get offer by ID * Claim a username
*/ */
async getOffer(offerId: string): Promise<Offer> { async claimUsername(username: string, publicKey: string): Promise<void> {
const auth = await this.generateAuthParams('getOffer', offerId); const auth = await this.generateAuth('claim', username)
const url = new URL(`${this.baseUrl}/offers/${offerId}`); await this.rpc({
url.searchParams.set('username', auth.username); method: 'claimUsername',
url.searchParams.set('signature', auth.signature); message: auth.message,
url.searchParams.set('message', auth.message); signature: auth.signature,
params: { username, publicKey },
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 }),
}) })
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 * Check if current username is claimed
* Returns all answered offers and ICE candidates since timestamp
*/ */
async poll(since?: number): Promise<{ async isUsernameClaimed(): Promise<boolean> {
answers: Array<{ const auth = await this.generateAuth('getUser', this.username)
offerId: string; const result = await this.rpc({
serviceId?: string; method: 'getUser',
answererId: string; message: auth.message,
sdp: string; signature: auth.signature,
answeredAt: number; params: { username: this.username },
}>; })
iceCandidates: Record<string, Array<{ return !result.available
candidate: any;
role: 'offerer' | 'answerer';
peerId: string;
createdAt: number;
}>>;
}> {
const auth = await this.generateAuthParams('poll', since?.toString() || '');
const url = new URL(`${this.baseUrl}/poll`);
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);
const response = await fetch(url.toString())
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to poll: ${error.error || response.statusText}`)
} }
return await response.json() // ============================================
// Service Management
// ============================================
/**
* Publish a service
*/
async publishService(service: ServiceRequest): Promise<Service> {
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,
},
})
}
/**
* Get service by FQN (direct lookup, random, or paginated)
*/
async getService(
serviceFqn: string,
options?: { limit?: number; offset?: number }
): Promise<any> {
const auth = await this.generateAuth('getService', serviceFqn)
return await this.rpc({
method: 'getService',
message: auth.message,
signature: auth.signature,
params: {
serviceFqn,
...options,
},
})
}
/**
* Delete a service
*/
async deleteService(serviceFqn: string): Promise<void> {
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<void> {
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) * 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> { async getOfferAnswer(
const auth = await this.generateAuthParams('getOfferAnswer', `${serviceFqn}:${offerId}`); serviceFqn: string,
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`); offerId: string
url.searchParams.set('username', auth.username); ): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
url.searchParams.set('signature', auth.signature); try {
url.searchParams.set('message', auth.message); const auth = await this.generateAuth('getOfferAnswer', offerId)
return await this.rpc({
const response = await fetch(url.toString()) method: 'getOfferAnswer',
message: auth.message,
if (!response.ok) { signature: auth.signature,
// 404 means not yet answered params: { serviceFqn, offerId },
if (response.status === 404) { })
} catch (err) {
if ((err as Error).message.includes('not yet answered')) {
return null return null
} }
const error = await response.json().catch(() => ({ error: 'Unknown error' })) throw err
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
} }
return await response.json()
} }
/** /**
* Search offers by topic * Combined polling for answers and ICE candidates
*/ */
async searchOffers(topic: string): Promise<Offer[]> { async poll(since?: number): Promise<{
const auth = await this.generateAuthParams('searchOffers', topic); answers: Array<{
const url = new URL(`${this.baseUrl}/offers`); offerId: string
url.searchParams.set('topic', topic); serviceId?: string
url.searchParams.set('username', auth.username); answererId: string
url.searchParams.set('signature', auth.signature); sdp: string
url.searchParams.set('message', auth.message); answeredAt: number
}>
const response = await fetch(url.toString()) iceCandidates: Record<
string,
if (!response.ok) { Array<{
const error = await response.json().catch(() => ({ error: 'Unknown error' })) candidate: any
throw new Error(`Failed to search offers: ${error.error || response.statusText}`) 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 },
})
} }
return await response.json()
}
// ============================================
// ICE Candidates
// ============================================
/** /**
* Add ICE candidates to a specific offer * Add ICE candidates to a specific offer
*/ */
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> { async addOfferIceCandidates(
const auth = await this.generateAuthParams('addIceCandidates', `${serviceFqn}:${offerId}`); serviceFqn: string,
offerId: string,
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, { candidates: RTCIceCandidateInit[]
method: 'POST', ): Promise<{ count: number; offerId: string }> {
headers: { const auth = await this.generateAuth('addIceCandidates', offerId)
'Content-Type': 'application/json', return await this.rpc({
}, method: 'addIceCandidates',
body: JSON.stringify({ candidates, ...auth }), 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 }> { async getOfferIceCandidates(
const auth = await this.generateAuthParams('getIceCandidates', `${serviceFqn}:${offerId}:${since}`); serviceFqn: string,
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`) offerId: string,
url.searchParams.set('since', since.toString()) since: number = 0
url.searchParams.set('username', auth.username); ): Promise<{ candidates: IceCandidate[]; offerId: string }> {
url.searchParams.set('signature', auth.signature); const auth = await this.generateAuth('getIceCandidates', `${offerId}:${since}`)
url.searchParams.set('message', auth.message); 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 { return {
candidates: data.candidates || [], candidates: result.candidates || [],
offerId: data.offerId offerId: result.offerId,
} }
} }
// ============================================
// Services
// ============================================
/**
* Publish a service
* Service FQN must include username: service:version@username
*/
async publishService(service: ServiceRequest): Promise<Service> {
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()
}
} }

View File

@@ -16,9 +16,9 @@ export type {
export type { export type {
Keypair, Keypair,
OfferRequest, OfferRequest,
Offer,
ServiceRequest, ServiceRequest,
Service, Service,
ServiceOffer,
IceCandidate, IceCandidate,
} from './api.js' } from './api.js'

View File

@@ -111,8 +111,7 @@ export class RondevuSignaler implements Signaler {
} }
// Send answer to the service // Send answer to the service
const result = await this.rondevu.getAPIPublic().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp) await this.rondevu.getAPIPublic().answerOffer(this.serviceFqn, this.offerId, answer.sdp)
this.offerId = result.offerId
this.isOfferer = false this.isOfferer = false
// Start polling for ICE candidates (answerer uses separate endpoint) // Start polling for ICE candidates (answerer uses separate endpoint)

View File

@@ -114,22 +114,19 @@ export class Rondevu {
} }
// Check if username is already claimed // Check if username is already claimed
const check = await this.getAPI().checkUsername(this.username) const available = await this.getAPI().isUsernameAvailable(this.username)
if (!check.available) { if (!available) {
// Verify it's claimed by us // Check if it's claimed by us
if (check.publicKey === this.keypair.publicKey) { const claimed = await this.getAPI().isUsernameClaimed()
if (claimed) {
this.usernameClaimed = true this.usernameClaimed = true
return return
} }
throw new Error(`Username "${this.username}" is already claimed by another user`) 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 // 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 this.usernameClaimed = true
} }
@@ -152,19 +149,7 @@ export class Rondevu {
} }
try { try {
const check = await this.getAPI().checkUsername(this.username) const claimed = await this.getAPI().isUsernameClaimed()
// 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
// Update internal flag to match server state // Update internal flag to match server state
this.usernameClaimed = claimed this.usernameClaimed = claimed
@@ -246,7 +231,7 @@ export class Rondevu {
createdAt: number createdAt: number
expiresAt: number expiresAt: number
}> { }> {
return await this.getAPI().discoverService(serviceVersion) return await this.getAPI().getService(serviceVersion)
} }
/** /**
@@ -267,7 +252,7 @@ export class Rondevu {
limit: number limit: number
offset: 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 success: boolean
offerId: string offerId: string
}> { }> {
return await this.getAPI().postOfferAnswer(serviceFqn, offerId, sdp) await this.getAPI().answerOffer(serviceFqn, offerId, sdp)
return { success: true, offerId }
} }
/** /**