mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-15 05:13:23 +00:00
v0.13.0: Major refactoring with unified Rondevu class and service discovery
- Renamed RondevuService to Rondevu as single main entrypoint - Integrated signaling methods directly into Rondevu class - Updated service FQN format: service:version@username (colon instead of @) - Added service discovery (direct, random, paginated) - Removed high-level abstractions (ServiceHost, ServiceClient, RTCDurableConnection, EventBus, WebRTCContext, Bin) - Updated RondevuAPI with new endpoint methods (offer-specific routes) - Simplified types (moved Binnable to types.ts, removed connection types) - Updated RondevuSignaler to use Rondevu class - Breaking changes: Complete API overhaul for simplicity 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
335
src/rondevu.ts
Normal file
335
src/rondevu.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest, IceCandidate } from './api.js'
|
||||
|
||||
export interface RondevuOptions {
|
||||
apiUrl: string
|
||||
username: string
|
||||
keypair?: Keypair
|
||||
credentials?: Credentials
|
||||
}
|
||||
|
||||
export interface PublishServiceOptions {
|
||||
serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice")
|
||||
offers: Array<{ sdp: string }>
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Rondevu - Complete WebRTC signaling client
|
||||
*
|
||||
* Provides a unified API for:
|
||||
* - Username claiming with Ed25519 signatures
|
||||
* - Service publishing with automatic signature generation
|
||||
* - Service discovery (direct, random, paginated)
|
||||
* - WebRTC signaling (offer/answer exchange, ICE relay)
|
||||
* - Keypair management
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Initialize (generates keypair automatically)
|
||||
* const rondevu = new Rondevu({
|
||||
* apiUrl: 'https://signal.example.com',
|
||||
* username: 'alice',
|
||||
* })
|
||||
*
|
||||
* await rondevu.initialize()
|
||||
*
|
||||
* // Claim username (one time)
|
||||
* await rondevu.claimUsername()
|
||||
*
|
||||
* // Publish a service
|
||||
* const publishedService = await rondevu.publishService({
|
||||
* serviceFqn: 'chat:1.0.0@alice',
|
||||
* offers: [{ sdp: offerSdp }],
|
||||
* ttl: 300000,
|
||||
* })
|
||||
*
|
||||
* // Discover a service
|
||||
* const service = await rondevu.getService('chat:1.0.0@bob')
|
||||
*
|
||||
* // Post answer
|
||||
* await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
|
||||
* ```
|
||||
*/
|
||||
export class Rondevu {
|
||||
private readonly api: RondevuAPI
|
||||
private readonly username: string
|
||||
private keypair: Keypair | null = null
|
||||
private usernameClaimed = false
|
||||
|
||||
constructor(options: RondevuOptions) {
|
||||
this.username = options.username
|
||||
this.keypair = options.keypair || null
|
||||
this.api = new RondevuAPI(options.apiUrl, options.credentials)
|
||||
|
||||
console.log('[Rondevu] Constructor called:', {
|
||||
username: this.username,
|
||||
hasKeypair: !!this.keypair,
|
||||
publicKey: this.keypair?.publicKey
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Initialize the service - generates keypair if not provided
|
||||
* Call this before using other methods
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair)
|
||||
|
||||
if (!this.keypair) {
|
||||
console.log('[Rondevu] Generating new keypair...')
|
||||
this.keypair = await RondevuAPI.generateKeypair()
|
||||
console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
|
||||
} else {
|
||||
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
|
||||
}
|
||||
|
||||
// Register with API if no credentials provided
|
||||
if (!this.api['credentials']) {
|
||||
const credentials = await this.api.register()
|
||||
this.api.setCredentials(credentials)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Username Management
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Claim the username with Ed25519 signature
|
||||
* Should be called once before publishing services
|
||||
*/
|
||||
async claimUsername(): Promise<void> {
|
||||
if (!this.keypair) {
|
||||
throw new Error('Not initialized. Call initialize() first.')
|
||||
}
|
||||
|
||||
// Check if username is already claimed
|
||||
const check = await this.api.checkUsername(this.username)
|
||||
if (!check.available) {
|
||||
// Verify it's claimed by us
|
||||
if (check.publicKey === this.keypair.publicKey) {
|
||||
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.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
|
||||
this.usernameClaimed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if username has been claimed (checks with server)
|
||||
*/
|
||||
async isUsernameClaimed(): Promise<boolean> {
|
||||
if (!this.keypair) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const check = await this.api.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
|
||||
|
||||
// Update internal flag to match server state
|
||||
this.usernameClaimed = claimed
|
||||
|
||||
return claimed
|
||||
} catch (err) {
|
||||
console.error('Failed to check username claim status:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Service Publishing
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Publish a service with automatic signature generation
|
||||
*/
|
||||
async publishService(options: PublishServiceOptions): Promise<Service> {
|
||||
if (!this.keypair) {
|
||||
throw new Error('Not initialized. Call initialize() first.')
|
||||
}
|
||||
|
||||
if (!this.usernameClaimed) {
|
||||
throw new Error(
|
||||
'Username not claimed. Call claimUsername() first or the server will reject the service.'
|
||||
)
|
||||
}
|
||||
|
||||
const { serviceFqn, offers, ttl } = options
|
||||
|
||||
// Generate signature for service publication
|
||||
const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`
|
||||
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
|
||||
|
||||
// Create service request
|
||||
const serviceRequest: ServiceRequest = {
|
||||
serviceFqn, // Must include @username
|
||||
offers,
|
||||
signature,
|
||||
message,
|
||||
ttl,
|
||||
}
|
||||
|
||||
// Publish to server
|
||||
return await this.api.publishService(serviceRequest)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Service Discovery
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
}> {
|
||||
return await this.api.getService(serviceFqn)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}> {
|
||||
return await this.api.discoverService(serviceVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}> {
|
||||
return await this.api.discoverServices(serviceVersion, limit, offset)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebRTC Signaling
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Post answer SDP to specific offer
|
||||
*/
|
||||
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{
|
||||
success: boolean
|
||||
offerId: string
|
||||
}> {
|
||||
return await this.api.postOfferAnswer(serviceFqn, offerId, sdp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get answer SDP (offerer polls this)
|
||||
*/
|
||||
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{
|
||||
sdp: string
|
||||
offerId: string
|
||||
answererId: string
|
||||
answeredAt: number
|
||||
} | null> {
|
||||
return await this.api.getOfferAnswer(serviceFqn, offerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ICE candidates to specific offer
|
||||
*/
|
||||
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{
|
||||
count: number
|
||||
offerId: string
|
||||
}> {
|
||||
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ICE candidates for specific offer (with polling support)
|
||||
*/
|
||||
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{
|
||||
candidates: IceCandidate[]
|
||||
offerId: string
|
||||
}> {
|
||||
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get the current keypair (for backup/storage)
|
||||
*/
|
||||
getKeypair(): Keypair | null {
|
||||
return this.keypair
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username
|
||||
*/
|
||||
getUsername(): string {
|
||||
return this.username
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key
|
||||
*/
|
||||
getPublicKey(): string | null {
|
||||
return this.keypair?.publicKey || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Access to underlying API for advanced operations
|
||||
* @deprecated Use direct methods on Rondevu instance instead
|
||||
*/
|
||||
getAPI(): RondevuAPI {
|
||||
return this.api
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user