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'
@@ -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<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 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<boolean> {
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<Offer[]> {
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<boolean> {
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<Offer> {
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<void> {
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<string, Array<{
candidate: any;
role: 'offerer' | 'answerer';
peerId: string;
createdAt: number;
}>>;
}> {
const auth = await this.generateAuthParams('poll', since?.toString() || '');
const url = new URL(`${this.baseUrl}/poll`);
async isUsernameClaimed(): Promise<boolean> {
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<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,
},
})
}
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<any> {
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<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)
*/
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<Offer[]> {
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<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 {
Keypair,
OfferRequest,
Offer,
ServiceRequest,
Service,
ServiceOffer,
IceCandidate,
} from './api.js'

View File

@@ -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)

View File

@@ -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 }
}
/**