Simplify API: Remove explicit claiming, add Rondevu.connect(), simplify publishService

Breaking changes:
- Remove claimUsername() method - username claiming now fully implicit
- Remove initialize() method - replaced with static Rondevu.connect()
- Change publishService() parameter from serviceFqn to service (username auto-appended)
- Make constructor private - must use Rondevu.connect()
- Remove nullable types from keypair/api - always initialized after connect()

New pattern:
const rondevu = await Rondevu.connect({ apiUrl, username })
await rondevu.publishService({ service: 'chat:2.0.0', offers })

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-12 21:56:37 +01:00
parent 7223e45b98
commit a2c01d530f
3 changed files with 170 additions and 180 deletions

120
README.md
View File

@@ -38,18 +38,13 @@ npm install @xtr-dev/rondevu-client
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'
// 1. Initialize (with username or anonymous)
const rondevu = new Rondevu({
// 1. Connect to Rondevu (generates keypair, username auto-claimed on first request)
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice' // Or omit for anonymous username
})
await rondevu.initialize() // Generates keypair automatically
// 2. Claim username (optional - anonymous users auto-claim)
await rondevu.claimUsername()
// 3. Create WebRTC offer
// 2. Create WebRTC offer
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
@@ -61,7 +56,7 @@ await pc.setLocalDescription(offer)
// 4. Publish service
const service = await rondevu.publishService({
serviceFqn: 'chat:1.0.0@alice',
service: 'chat:1.0.0',
offers: [{ sdp: offer.sdp }],
ttl: 300000
})
@@ -120,15 +115,12 @@ dc.onopen = () => {
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'
// 1. Initialize
const rondevu = new Rondevu({
// 1. Connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'bob'
})
await rondevu.initialize()
await rondevu.claimUsername()
// 2. Get service offer
const serviceData = await rondevu.getService('chat:1.0.0@alice')
@@ -201,11 +193,13 @@ Main class for all Rondevu operations.
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'
const rondevu = new Rondevu({
// Create and connect to Rondevu
const rondevu = await Rondevu.connect({
apiUrl: string, // Signaling server URL
username?: string, // Optional: your username (auto-generates anonymous if omitted)
keypair?: Keypair, // Optional: reuse existing keypair
cryptoAdapter?: CryptoAdapter // Optional: platform-specific crypto (defaults to WebCryptoAdapter)
batching?: BatcherOptions | false // Optional: RPC batching configuration
})
```
@@ -218,7 +212,7 @@ The client supports both browser and Node.js environments using crypto adapters:
import { Rondevu } from '@xtr-dev/rondevu-client'
// WebCryptoAdapter is used by default - no configuration needed
const rondevu = new Rondevu({
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
@@ -228,13 +222,11 @@ const rondevu = new Rondevu({
```typescript
import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'
const rondevu = new Rondevu({
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice',
cryptoAdapter: new NodeCryptoAdapter()
})
await rondevu.initialize()
```
**Note:** Node.js support requires:
@@ -255,25 +247,17 @@ class CustomCryptoAdapter implements CryptoAdapter {
randomBytes(length: number): Uint8Array { /* ... */ }
}
const rondevu = new Rondevu({
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
cryptoAdapter: new CustomCryptoAdapter()
})
```
#### Initialization
```typescript
// Initialize (generates keypair if not provided, auto-claims anonymous usernames)
await rondevu.initialize(): Promise<void>
```
#### Username Management
```typescript
// Claim username with Ed25519 signature
await rondevu.claimUsername(): Promise<void>
Usernames are **automatically claimed** on the first authenticated request (like `publishService()`).
```typescript
// Check if username is claimed (checks server)
await rondevu.isUsernameClaimed(): Promise<boolean>
@@ -281,10 +265,10 @@ await rondevu.isUsernameClaimed(): Promise<boolean>
rondevu.getUsername(): string
// Get public key
rondevu.getPublicKey(): string | null
rondevu.getPublicKey(): string
// Get keypair (for backup/storage)
rondevu.getKeypair(): Keypair | null
rondevu.getKeypair(): Keypair
```
#### Service Publishing
@@ -292,9 +276,9 @@ rondevu.getKeypair(): Keypair | null
```typescript
// Publish service with offers
await rondevu.publishService({
serviceFqn: string, // e.g., 'chat:1.0.0@alice'
service: string, // e.g., 'chat:1.0.0' (username auto-appended)
offers: Array<{ sdp: string }>,
ttl?: number // Optional: milliseconds (default: 300000)
ttl?: number // Optional: milliseconds (default: 300000)
}): Promise<Service>
```
@@ -452,13 +436,8 @@ await api.checkUsername(username: string): Promise<{
expiresAt?: number
}>
// Claim username
await api.claimUsername(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ success: boolean, username: string }>
// Note: Username claiming is now implicit - usernames are auto-claimed
// on first authenticated request to the server
// ... (all other HTTP endpoints)
```
@@ -527,18 +506,16 @@ interface PollingConfig {
```typescript
// Auto-generate anonymous username (format: anon-{timestamp}-{random})
const rondevu = new Rondevu({
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu'
// No username provided - will generate anonymous username
})
await rondevu.initialize() // Auto-claims anonymous username
console.log(rondevu.getUsername()) // e.g., "anon-lx2w34-a3f501"
// Anonymous users behave exactly like regular users
await rondevu.publishService({
serviceFqn: `chat:1.0.0@${rondevu.getUsername()}`,
service: 'chat:1.0.0',
offers: [{ sdp: offerSdp }]
})
```
@@ -547,15 +524,12 @@ await rondevu.publishService({
```typescript
// Save keypair and username to localStorage
const rondevu = new Rondevu({
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
await rondevu.initialize()
await rondevu.claimUsername()
// Save for later
// Save for later (username will be auto-claimed on first authenticated request)
localStorage.setItem('rondevu-username', rondevu.getUsername())
localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair()))
@@ -563,13 +537,11 @@ localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair()))
const savedUsername = localStorage.getItem('rondevu-username')
const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair'))
const rondevu2 = new Rondevu({
const rondevu2 = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: savedUsername,
keypair: savedKeypair
})
await rondevu2.initialize() // Reuses keypair
```
### Service Discovery
@@ -603,7 +575,7 @@ for (let i = 0; i < 5; i++) {
}
const service = await rondevu.publishService({
serviceFqn: 'chat:1.0.0@alice',
service: 'chat:1.0.0',
offers,
ttl: 300000
})
@@ -666,7 +638,45 @@ const pc = new RTCPeerConnection()
## Examples
See the [demo](https://github.com/xtr-dev/rondevu-demo) for a complete working example with React UI.
### Node.js Service Host Example
You can host WebRTC services in Node.js that browser clients can connect to. See the [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) for a complete guide.
**Quick example:**
```typescript
import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'
import wrtc from 'wrtc'
const { RTCPeerConnection } = wrtc
// Initialize with Node crypto adapter
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.ronde.vu',
username: 'mybot',
cryptoAdapter: new NodeCryptoAdapter()
})
// Create peer connection (offerer creates data channel)
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
// Publish service (username auto-claimed on first publish)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await rondevu.publishService({
service: 'chat:1.0.0',
offers: [{ sdp: offer.sdp }]
})
// Browser clients can now discover and connect to chat:1.0.0@mybot
```
See complete examples:
- [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) - Full guide with complete examples
- [test-connect.js](../demo/test-connect.js) - Working Node.js client example
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Complete browser UI ([live](https://ronde.vu))
## Migration from v0.3.x

View File

@@ -157,7 +157,21 @@ export class RondevuAPI {
const results: RpcResponse[] = await response.json()
// Validate response is an array
if (!Array.isArray(results)) {
console.error('Invalid RPC batch response:', results)
throw new Error('Server returned invalid batch response (not an array)')
}
// Check response length matches request length
if (results.length !== requests.length) {
console.error(`Response length mismatch: expected ${requests.length}, got ${results.length}`)
}
return results.map((result, i) => {
if (!result || typeof result !== 'object') {
throw new Error(`Invalid response at index ${i}`)
}
if (!result.success) {
throw new Error(result.error || `RPC call ${i} failed`)
}
@@ -223,20 +237,6 @@ export class RondevuAPI {
return result.available
}
/**
* Claim a username
*/
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,
publicKey: this.keypair.publicKey,
params: { username, publicKey },
})
}
/**
* Check if current username is claimed
*/

View File

@@ -10,7 +10,7 @@ export interface RondevuOptions {
}
export interface PublishServiceOptions {
serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice")
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
offers: Array<{ sdp: string }>
ttl?: number
}
@@ -19,7 +19,7 @@ export interface PublishServiceOptions {
* Rondevu - Complete WebRTC signaling client
*
* Provides a unified API for:
* - Username claiming with Ed25519 signatures
* - Implicit username claiming (auto-claimed on first authenticated request)
* - Service publishing with automatic signature generation
* - Service discovery (direct, random, paginated)
* - WebRTC signaling (offer/answer exchange, ICE relay)
@@ -27,17 +27,15 @@ export interface PublishServiceOptions {
*
* @example
* ```typescript
* // Create Rondevu instance with username
* const rondevu = new Rondevu({
* // Create and initialize Rondevu instance
* const rondevu = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'alice',
* })
* await rondevu.initialize()
* await rondevu.claimUsername() // Claim username before publishing
*
* // Publish a service
* // Publish a service (username auto-claimed on first publish)
* const publishedService = await rondevu.publishService({
* serviceFqn: 'chat:1.0.0@alice',
* service: 'chat:1.0.0',
* offers: [{ sdp: offerSdp }],
* ttl: 300000,
* })
@@ -50,120 +48,106 @@ export interface PublishServiceOptions {
* ```
*/
export class Rondevu {
private api: RondevuAPI | null = null
private api: RondevuAPI
private readonly apiUrl: string
private username: string
private keypair: Keypair | null = null
private keypair: Keypair
private usernameClaimed = false
private cryptoAdapter?: CryptoAdapter
private batchingOptions?: BatcherOptions | false
constructor(options: RondevuOptions) {
this.apiUrl = options.apiUrl
this.username = options.username || this.generateAnonymousUsername()
this.keypair = options.keypair || null
this.cryptoAdapter = options.cryptoAdapter
this.batchingOptions = options.batching
private constructor(
apiUrl: string,
username: string,
keypair: Keypair,
api: RondevuAPI,
cryptoAdapter?: CryptoAdapter,
batchingOptions?: BatcherOptions | false
) {
this.apiUrl = apiUrl
this.username = username
this.keypair = keypair
this.api = api
this.cryptoAdapter = cryptoAdapter
this.batchingOptions = batchingOptions
console.log('[Rondevu] Constructor called:', {
console.log('[Rondevu] Instance created:', {
username: this.username,
hasKeypair: !!this.keypair,
publicKey: this.keypair?.publicKey,
publicKey: this.keypair.publicKey,
batchingEnabled: batchingOptions !== false
})
}
/**
* Create and initialize a Rondevu client
*
* @example
* ```typescript
* const rondevu = await Rondevu.connect({
* apiUrl: 'https://api.ronde.vu',
* username: 'alice'
* })
* ```
*/
static async connect(options: RondevuOptions): Promise<Rondevu> {
const username = options.username || Rondevu.generateAnonymousUsername()
console.log('[Rondevu] Connecting:', {
username,
hasKeypair: !!options.keypair,
batchingEnabled: options.batching !== false
})
// Generate keypair if not provided
let keypair = options.keypair
if (!keypair) {
console.log('[Rondevu] Generating new keypair...')
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter)
console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey)
} else {
console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey)
}
// Create API instance
const api = new RondevuAPI(
options.apiUrl,
username,
keypair,
options.cryptoAdapter,
options.batching
)
console.log('[Rondevu] Created API instance')
return new Rondevu(
options.apiUrl,
username,
keypair,
api,
options.cryptoAdapter,
options.batching
)
}
/**
* Generate an anonymous username with timestamp and random component
*/
private generateAnonymousUsername(): string {
private static generateAnonymousUsername(): string {
const timestamp = Date.now().toString(36)
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
.map(b => b.toString(16).padStart(2, '0')).join('')
return `anon-${timestamp}-${random}`
}
// ============================================
// Initialization
// ============================================
/**
* Initialize the service - generates keypair if not provided and creates API instance
* Call this before using other methods
*/
async initialize(): Promise<void> {
console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair)
// Generate keypair if not provided
if (!this.keypair) {
console.log('[Rondevu] Generating new keypair...')
this.keypair = await RondevuAPI.generateKeypair(this.cryptoAdapter)
console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
} else {
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
}
// Create API instance with username, keypair, crypto adapter, and batching options
this.api = new RondevuAPI(
this.apiUrl,
this.username,
this.keypair,
this.cryptoAdapter,
this.batchingOptions
)
console.log('[Rondevu] Created API instance with username:', this.username)
}
// ============================================
// 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 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`)
}
// Claim the username
await this.getAPI().claimUsername(this.username, this.keypair.publicKey)
this.usernameClaimed = true
}
/**
* Get API instance (creates lazily if needed)
*/
private getAPI(): RondevuAPI {
if (!this.api) {
throw new Error('Not initialized. Call initialize() first.')
}
return this.api
}
/**
* Check if username has been claimed (checks with server)
*/
async isUsernameClaimed(): Promise<boolean> {
if (!this.keypair) {
return false
}
try {
const claimed = await this.getAPI().isUsernameClaimed()
const claimed = await this.api.isUsernameClaimed()
// Update internal flag to match server state
this.usernameClaimed = claimed
@@ -184,15 +168,14 @@ export class Rondevu {
* Username will be automatically claimed on first publish if not already claimed
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('Not initialized. Call initialize() first.')
}
const { service, offers, ttl } = options
const { serviceFqn, offers, ttl } = options
// Auto-append username to service
const serviceFqn = `${service}@${this.username}`
// Publish to server (server will auto-claim username if needed)
// Note: signature and message are generated by the API layer
const result = await this.getAPI().publishService({
const result = await this.api.publishService({
serviceFqn,
offers,
ttl,
@@ -223,7 +206,7 @@ export class Rondevu {
createdAt: number
expiresAt: number
}> {
return await this.getAPI().getService(serviceFqn)
return await this.api.getService(serviceFqn)
}
/**
@@ -239,7 +222,7 @@ export class Rondevu {
createdAt: number
expiresAt: number
}> {
return await this.getAPI().getService(serviceVersion)
return await this.api.getService(serviceVersion)
}
/**
@@ -260,7 +243,7 @@ export class Rondevu {
limit: number
offset: number
}> {
return await this.getAPI().getService(serviceVersion, { limit, offset })
return await this.api.getService(serviceVersion, { limit, offset })
}
// ============================================
@@ -274,7 +257,7 @@ export class Rondevu {
success: boolean
offerId: string
}> {
await this.getAPI().answerOffer(serviceFqn, offerId, sdp)
await this.api.answerOffer(serviceFqn, offerId, sdp)
return { success: true, offerId }
}
@@ -287,7 +270,7 @@ export class Rondevu {
answererId: string
answeredAt: number
} | null> {
return await this.getAPI().getOfferAnswer(serviceFqn, offerId)
return await this.api.getOfferAnswer(serviceFqn, offerId)
}
/**
@@ -309,7 +292,7 @@ export class Rondevu {
createdAt: number
}>>
}> {
return await this.getAPI().poll(since)
return await this.api.poll(since)
}
/**
@@ -319,7 +302,7 @@ export class Rondevu {
count: number
offerId: string
}> {
return await this.getAPI().addOfferIceCandidates(serviceFqn, offerId, candidates)
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates)
}
/**
@@ -329,7 +312,7 @@ export class Rondevu {
candidates: IceCandidate[]
offerId: string
}> {
return await this.getAPI().getOfferIceCandidates(serviceFqn, offerId, since)
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since)
}
// ============================================
@@ -339,7 +322,7 @@ export class Rondevu {
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
getKeypair(): Keypair {
return this.keypair
}
@@ -353,8 +336,8 @@ export class Rondevu {
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
getPublicKey(): string {
return this.keypair.publicKey
}
/**
@@ -362,9 +345,6 @@ export class Rondevu {
* @deprecated Use direct methods on Rondevu instance instead
*/
getAPIPublic(): RondevuAPI {
if (!this.api) {
throw new Error('Not initialized. Call initialize() first.')
}
return this.api
}
}