mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-12 03:43:24 +00:00
Add ServiceHost, ServiceClient, and RondevuService for high-level service management
- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures - Add ServiceHost: Manages offer pool for hosting services with auto-replacement - Add ServiceClient: Connects to hosted services with automatic reconnection - Add NoOpSignaler: Placeholder signaler for connection setup - Integrate Ed25519 signature functionality from @noble/ed25519 - Add ESLint and Prettier configuration with 4-space indentation - Add demo with local signaling test - Version bump to 0.10.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
295
src/api.ts
295
src/api.ts
@@ -2,55 +2,83 @@
|
||||
* Rondevu API Client - Single class for all API endpoints
|
||||
*/
|
||||
|
||||
import * as ed25519 from '@noble/ed25519'
|
||||
|
||||
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
|
||||
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
|
||||
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
peerId: string;
|
||||
secret: string;
|
||||
peerId: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export interface Keypair {
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export interface OfferRequest {
|
||||
sdp: string;
|
||||
topics?: string[];
|
||||
ttl?: number;
|
||||
secret?: string;
|
||||
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;
|
||||
id: string
|
||||
peerId: string
|
||||
sdp: string
|
||||
topics: string[]
|
||||
ttl: number
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
answererPeerId?: string
|
||||
}
|
||||
|
||||
export interface ServiceRequest {
|
||||
username: string;
|
||||
serviceFqn: string;
|
||||
sdp: string;
|
||||
ttl?: number;
|
||||
isPublic?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
signature: string;
|
||||
message: string;
|
||||
username: string
|
||||
serviceFqn: string
|
||||
sdp: string
|
||||
ttl?: number
|
||||
isPublic?: boolean
|
||||
metadata?: Record<string, any>
|
||||
signature: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
serviceId: string;
|
||||
uuid: string;
|
||||
offerId: string;
|
||||
username: string;
|
||||
serviceFqn: string;
|
||||
isPublic: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
serviceId: string
|
||||
uuid: string
|
||||
offerId: string
|
||||
username: string
|
||||
serviceFqn: string
|
||||
isPublic: boolean
|
||||
metadata?: Record<string, any>
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface IceCandidate {
|
||||
candidate: RTCIceCandidateInit;
|
||||
createdAt: number;
|
||||
candidate: RTCIceCandidateInit
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Uint8Array to base64 string
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
|
||||
return btoa(binString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert base64 string to Uint8Array
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binString = atob(base64)
|
||||
return Uint8Array.from(binString, char => char.codePointAt(0)!)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,11 +95,56 @@ export class RondevuAPI {
|
||||
*/
|
||||
private getAuthHeader(): Record<string, string> {
|
||||
if (!this.credentials) {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
||||
};
|
||||
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Ed25519 Cryptography Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate an Ed25519 keypair for username claiming and service publishing
|
||||
*/
|
||||
static async generateKeypair(): Promise<Keypair> {
|
||||
const privateKey = ed25519.utils.randomSecretKey()
|
||||
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
|
||||
|
||||
return {
|
||||
publicKey: bytesToBase64(publicKey),
|
||||
privateKey: bytesToBase64(privateKey),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with an Ed25519 private key
|
||||
*/
|
||||
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
|
||||
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
|
||||
*/
|
||||
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)
|
||||
|
||||
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -84,15 +157,15 @@ export class RondevuAPI {
|
||||
async register(): Promise<Credentials> {
|
||||
const response = await fetch(`${this.baseUrl}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Registration failed: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Registration failed: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -107,17 +180,17 @@ export class RondevuAPI {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ offers })
|
||||
});
|
||||
body: JSON.stringify({ offers }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to create offers: ${error.error || response.statusText}`);
|
||||
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 await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,15 +198,15 @@ export class RondevuAPI {
|
||||
*/
|
||||
async getOffer(offerId: string): Promise<Offer> {
|
||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get offer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,14 +217,14 @@ export class RondevuAPI {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ sdp, secret })
|
||||
});
|
||||
body: JSON.stringify({ sdp, secret }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,19 +233,19 @@ export class RondevuAPI {
|
||||
*/
|
||||
async getAnswer(offerId: string): Promise<{ sdp: string } | null> {
|
||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
return null; // No answer yet
|
||||
return null // No answer yet
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get answer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,15 +253,15 @@ export class RondevuAPI {
|
||||
*/
|
||||
async searchOffers(topic: string): Promise<Offer[]> {
|
||||
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search offers: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -203,14 +276,14 @@ export class RondevuAPI {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ candidates })
|
||||
});
|
||||
body: JSON.stringify({ 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}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +294,14 @@ export class RondevuAPI {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
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 error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -243,17 +316,17 @@ export class RondevuAPI {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify(service)
|
||||
});
|
||||
body: JSON.stringify(service),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to publish service: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,15 +334,15 @@ export class RondevuAPI {
|
||||
*/
|
||||
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get service: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,14 +352,14 @@ export class RondevuAPI {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,14 +369,14 @@ export class RondevuAPI {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,14 +386,14 @@ export class RondevuAPI {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -333,14 +406,14 @@ export class RondevuAPI {
|
||||
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/usernames/${encodeURIComponent(username)}/check`
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to check username: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,20 +429,20 @@ export class RondevuAPI {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publicKey,
|
||||
signature,
|
||||
message
|
||||
})
|
||||
});
|
||||
message,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user