Files
rondevu-client/src/rondevu.ts
Bas van den Aakster eb280e3826 Fix ICE candidate handling for Node.js compatibility
Handle both browser and Node.js (wrtc) environments when processing ICE candidates. Browsers provide toJSON() method on candidates, but wrtc library candidates are already plain objects. Check for toJSON() existence before calling it.

Fixes: TypeError: event.candidate.toJSON is not a function in Node.js
2025-12-13 15:19:45 +01:00

896 lines
28 KiB
TypeScript

import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
// ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
// ICE server presets
export const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]> = {
'ipv4-turn': [
{ urls: 'stun:57.129.61.67:3478' },
{
urls: [
'turn:57.129.61.67:3478?transport=tcp',
'turn:57.129.61.67:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword'
}
],
'hostname-turns': [
{ urls: 'stun:turn.share.fish:3478' },
{
urls: [
'turns:turn.share.fish:5349?transport=tcp',
'turns:turn.share.fish:5349?transport=udp',
'turn:turn.share.fish:3478?transport=tcp',
'turn:turn.share.fish:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword'
}
],
'google-stun': [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
],
'relay-only': [
{ urls: 'stun:57.129.61.67:3478' },
{
urls: [
'turn:57.129.61.67:3478?transport=tcp',
'turn:57.129.61.67:3478?transport=udp',
],
username: 'webrtcuser',
credential: 'supersecretpassword',
// @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
iceTransportPolicy: 'relay'
}
]
}
export interface RondevuOptions {
apiUrl: string
username?: string // Optional, will generate anonymous if not provided
keypair?: Keypair // Optional, will generate if not provided
cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter
batching?: BatcherOptions | false // Optional, defaults to enabled with default options
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
debug?: boolean // Optional: enable debug logging (default: false)
}
export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel
offer: RTCSessionDescriptionInit
}
export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>
export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
maxOffers: number // Maximum number of concurrent offers to maintain
offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel)
ttl?: number // Time-to-live for offers in milliseconds (default: 300000)
}
export interface ConnectionContext {
pc: RTCPeerConnection
dc: RTCDataChannel
serviceFqn: string
offerId: string
peerUsername: string
}
export interface ConnectToServiceOptions {
serviceFqn?: string // Full FQN like 'chat:2.0.0@alice'
service?: string // Service without username (for discovery)
username?: string // Target username (combined with service)
onConnection?: (context: ConnectionContext) => void | Promise<void> // Called when data channel opens
rtcConfig?: RTCConfiguration // Optional: override default ICE servers
}
interface ActiveOffer {
offerId: string
serviceFqn: string
pc: RTCPeerConnection
dc?: RTCDataChannel
answered: boolean
createdAt: number
}
/**
* Rondevu - Complete WebRTC signaling client
*
* Provides a unified API for:
* - 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)
* - Keypair management
*
* @example
* ```typescript
* // Create and initialize Rondevu instance with preset ICE servers
* const rondevu = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'alice',
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
* })
*
* // Or use custom ICE servers
* const rondevu2 = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'bob',
* iceServers: [
* { urls: 'stun:stun.l.google.com:19302' },
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
* ]
* })
*
* // Publish a service with automatic offer management
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => {
* const pc = new RTCPeerConnection(rtcConfig)
* const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer()
* await pc.setLocalDescription(offer)
* return { pc, dc, offer }
* }
* })
*
* // Start accepting connections (auto-fills offers and polls)
* await rondevu.startFilling()
*
* // Access active connections
* for (const offer of rondevu.getActiveOffers()) {
* offer.dc?.addEventListener('message', (e) => console.log(e.data))
* }
*
* // Stop when done
* rondevu.stopFilling()
* ```
*/
export class Rondevu {
// Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
private api: RondevuAPI
private readonly apiUrl: string
private username: string
private keypair: Keypair
private usernameClaimed = false
private cryptoAdapter?: CryptoAdapter
private batchingOptions?: BatcherOptions | false
private iceServers: RTCIceServer[]
private debugEnabled: boolean
// Service management
private currentService: string | null = null
private maxOffers = 0
private offerFactory: OfferFactory | null = null
private ttl = Rondevu.DEFAULT_TTL_MS
private activeOffers = new Map<string, ActiveOffer>()
// Polling
private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
private constructor(
apiUrl: string,
username: string,
keypair: Keypair,
api: RondevuAPI,
iceServers: RTCIceServer[],
cryptoAdapter?: CryptoAdapter,
batchingOptions?: BatcherOptions | false,
debugEnabled = false
) {
this.apiUrl = apiUrl
this.username = username
this.keypair = keypair
this.api = api
this.iceServers = iceServers
this.cryptoAdapter = cryptoAdapter
this.batchingOptions = batchingOptions
this.debugEnabled = debugEnabled
this.debug('Instance created:', {
username: this.username,
publicKey: this.keypair.publicKey,
hasIceServers: iceServers.length > 0,
batchingEnabled: batchingOptions !== false
})
}
/**
* Internal debug logging - only logs if debug mode is enabled
*/
private debug(message: string, ...args: any[]): void {
if (this.debugEnabled) {
console.log(`[Rondevu] ${message}`, ...args)
}
}
/**
* 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()
// Handle preset string or custom array
let iceServers: RTCIceServer[]
if (typeof options.iceServers === 'string') {
iceServers = ICE_SERVER_PRESETS[options.iceServers]
} else {
iceServers = options.iceServers || [
{ urls: 'stun:stun.l.google.com:19302' }
]
}
if (options.debug) {
console.log('[Rondevu] Connecting:', {
username,
hasKeypair: !!options.keypair,
iceServers: iceServers.length,
batchingEnabled: options.batching !== false
})
}
// Generate keypair if not provided
let keypair = options.keypair
if (!keypair) {
if (options.debug) console.log('[Rondevu] Generating new keypair...')
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter)
if (options.debug) console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey)
} else {
if (options.debug) console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey)
}
// Create API instance
const api = new RondevuAPI(
options.apiUrl,
username,
keypair,
options.cryptoAdapter,
options.batching
)
if (options.debug) console.log('[Rondevu] Created API instance')
return new Rondevu(
options.apiUrl,
username,
keypair,
api,
iceServers,
options.cryptoAdapter,
options.batching,
options.debug || false
)
}
/**
* Generate an anonymous username with timestamp and random component
*/
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}`
}
// ============================================
// Username Management
// ============================================
/**
* Check if username has been claimed (checks with server)
*/
async isUsernameClaimed(): Promise<boolean> {
try {
const claimed = await this.api.isUsernameClaimed()
// 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
// ============================================
/**
* Default offer factory - creates a simple data channel connection
*/
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default')
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
}
/**
* Publish a service with automatic offer management
* Call startFilling() to begin accepting connections
*
* @example
* ```typescript
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5
* })
* await rondevu.startFilling()
* ```
*/
async publishService(options: PublishServiceOptions): Promise<void> {
const { service, maxOffers, offerFactory, ttl } = options
this.currentService = service
this.maxOffers = maxOffers
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this)
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`)
this.usernameClaimed = true
}
/**
* Set up ICE candidate handler to send candidates to the server
*/
private setupIceCandidateHandler(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): void {
pc.onicecandidate = async (event) => {
if (event.candidate) {
try {
// Handle both browser and Node.js (wrtc) environments
// Browser: candidate.toJSON() exists
// Node.js wrtc: candidate is already a plain object
const candidateData = typeof event.candidate.toJSON === 'function'
? event.candidate.toJSON()
: event.candidate
await this.api.addOfferIceCandidates(
serviceFqn,
offerId,
[candidateData]
)
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
}
}
}
/**
* Create a single offer and publish it to the server
*/
private async createOffer(): Promise<void> {
if (!this.currentService || !this.offerFactory) {
throw new Error('Service not published. Call publishService() first.')
}
const rtcConfig: RTCConfiguration = {
iceServers: this.iceServers
}
this.debug('Creating new offer...')
// Create the offer using the factory
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}`
// Publish to server
const result = await this.api.publishService({
serviceFqn,
offers: [{ sdp: offer.sdp! }],
ttl: this.ttl,
signature: '',
message: '',
})
const offerId = result.offers[0].offerId
// Store active offer
this.activeOffers.set(offerId, {
offerId,
serviceFqn,
pc,
dc,
answered: false,
createdAt: Date.now()
})
this.debug(`Offer created: ${offerId}`)
// Set up ICE candidate handler
this.setupIceCandidateHandler(pc, serviceFqn, offerId)
// Monitor connection state
pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.activeOffers.delete(offerId)
this.fillOffers() // Try to replace failed offer
}
}
}
/**
* Fill offers to reach maxOffers count
*/
private async fillOffers(): Promise<void> {
if (!this.filling || !this.currentService) return
const currentCount = this.activeOffers.size
const needed = this.maxOffers - currentCount
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
for (let i = 0; i < needed; i++) {
try {
await this.createOffer()
} catch (err) {
console.error('[Rondevu] Failed to create offer:', err)
}
}
}
/**
* Poll for answers and ICE candidates (internal use for automatic offer management)
*/
private async pollInternal(): Promise<void> {
if (!this.filling) return
try {
const result = await this.api.poll(this.lastPollTimestamp)
// Process answers
for (const answer of result.answers) {
const activeOffer = this.activeOffers.get(answer.offerId)
if (activeOffer && !activeOffer.answered) {
this.debug(`Received answer for offer ${answer.offerId}`)
await activeOffer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
})
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
// Create replacement offer
this.fillOffers()
}
}
// Process ICE candidates
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
const activeOffer = this.activeOffers.get(offerId)
if (activeOffer) {
const answererCandidates = candidates.filter(c => c.role === 'answerer')
for (const item of answererCandidates) {
if (item.candidate) {
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
}
}
}
}
} catch (err) {
console.error('[Rondevu] Polling error:', err)
}
}
/**
* Start filling offers and polling for answers/ICE
* Call this after publishService() to begin accepting connections
*/
async startFilling(): Promise<void> {
if (this.filling) {
this.debug('Already filling')
return
}
if (!this.currentService) {
throw new Error('No service published. Call publishService() first.')
}
this.debug('Starting offer filling and polling')
this.filling = true
// Fill initial offers
await this.fillOffers()
// Start polling
this.pollingInterval = setInterval(() => {
this.pollInternal()
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Stop filling offers and polling
* Closes all active peer connections
*/
stopFilling(): void {
this.debug('Stopping offer filling and polling')
this.filling = false
// Stop polling
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
// Close all active connections
for (const [offerId, offer] of this.activeOffers.entries()) {
this.debug(`Closing offer ${offerId}`)
offer.dc?.close()
offer.pc.close()
}
this.activeOffers.clear()
}
/**
* Resolve the full service FQN from various input options
* Supports direct FQN, service+username, or service discovery
*/
private async resolveServiceFqn(options: ConnectToServiceOptions): Promise<string> {
const { serviceFqn, service, username } = options
if (serviceFqn) {
return serviceFqn
} else if (service && username) {
return `${service}@${username}`
} else if (service) {
// Discovery mode - get random service
this.debug(`Discovering service: ${service}`)
const discovered = await this.discoverService(service)
return discovered.serviceFqn
} else {
throw new Error('Either serviceFqn or service must be provided')
}
}
/**
* Start polling for remote ICE candidates
* Returns the polling interval ID
*/
private startIcePolling(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): ReturnType<typeof setInterval> {
let lastIceTimestamp = 0
return setInterval(async () => {
try {
const result = await this.api.getOfferIceCandidates(
serviceFqn,
offerId,
lastIceTimestamp
)
for (const item of result.candidates) {
if (item.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt
}
}
} catch (err) {
console.error('[Rondevu] Failed to poll ICE candidates:', err)
}
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Automatically connect to a service (answerer side)
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
*
* @example
* ```typescript
* // Connect to specific user
* const connection = await rondevu.connectToService({
* serviceFqn: 'chat:2.0.0@alice',
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* dc.addEventListener('message', (e) => console.log(e.data))
* dc.addEventListener('open', () => dc.send('Hello!'))
* }
* })
*
* // Discover random service
* const connection = await rondevu.connectToService({
* service: 'chat:2.0.0',
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* }
* })
* ```
*/
async connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext> {
const { onConnection, rtcConfig } = options
// Validate inputs
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
throw new Error('serviceFqn cannot be empty')
}
if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
throw new Error('service cannot be empty')
}
if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
throw new Error('username cannot be empty')
}
// Determine the full service FQN
const fqn = await this.resolveServiceFqn(options)
this.debug(`Connecting to service: ${fqn}`)
// 1. Get service offer
const serviceData = await this.api.getService(fqn)
this.debug(`Found service from @${serviceData.username}`)
// 2. Create RTCPeerConnection
const rtcConfiguration = rtcConfig || {
iceServers: this.iceServers
}
const pc = new RTCPeerConnection(rtcConfiguration)
// 3. Set up data channel handler (answerer receives it from offerer)
let dc: RTCDataChannel | null = null
const dataChannelPromise = new Promise<RTCDataChannel>((resolve) => {
pc.ondatachannel = (event) => {
this.debug('Data channel received from offerer')
dc = event.channel
resolve(dc)
}
})
// 4. Set up ICE candidate exchange
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId)
// 5. Poll for remote ICE candidates
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId)
// 6. Set remote description
await pc.setRemoteDescription({
type: 'offer',
sdp: serviceData.sdp
})
// 7. Create and send answer
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await this.api.answerOffer(
serviceData.serviceFqn,
serviceData.offerId,
answer.sdp!
)
// 8. Wait for data channel to be established
dc = await dataChannelPromise
// Create connection context
const context: ConnectionContext = {
pc,
dc,
serviceFqn: serviceData.serviceFqn,
offerId: serviceData.offerId,
peerUsername: serviceData.username
}
// 9. Set up connection state monitoring
pc.onconnectionstatechange = () => {
this.debug(`Connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
clearInterval(icePollInterval)
}
}
// 10. Wait for data channel to open and call onConnection
if (dc.readyState === 'open') {
this.debug('Data channel already open')
if (onConnection) {
await onConnection(context)
}
} else {
await new Promise<void>((resolve) => {
dc!.addEventListener('open', async () => {
this.debug('Data channel opened')
if (onConnection) {
await onConnection(context)
}
resolve()
})
})
}
return context
}
// ============================================
// 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.getService(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.getService(serviceVersion, { limit, offset })
}
// ============================================
// WebRTC Signaling
// ============================================
/**
* Post answer SDP to specific offer
*/
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{
success: boolean
offerId: string
}> {
await this.api.answerOffer(serviceFqn, offerId, sdp)
return { success: true, offerId }
}
/**
* 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)
}
/**
* Combined polling for answers and ICE candidates
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
*/
async poll(since?: number): Promise<{
answers: Array<{
offerId: string
serviceId?: string
answererId: string
sdp: string
answeredAt: number
}>
iceCandidates: Record<string, Array<{
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
peerId: string
createdAt: number
}>>
}> {
return await this.api.poll(since)
}
/**
* 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 {
return this.keypair
}
/**
* Get the username
*/
getUsername(): string {
return this.username
}
/**
* Get the public key
*/
getPublicKey(): string {
return this.keypair.publicKey
}
/**
* Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead
*/
getAPIPublic(): RondevuAPI {
return this.api
}
}