mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-14 21:03:23 +00:00
Allow users to pass WebRTC polyfills (RTCPeerConnection, RTCIceCandidate) through RondevuOptions instead of manually setting global variables. The client now automatically applies these to globalThis when provided.
This simplifies Node.js integration:
- Before: Users had to manually set globalThis.RTCPeerConnection
- After: Pass rtcPeerConnection and rtcIceCandidate options
Example:
const rondevu = await Rondevu.connect({
apiUrl: 'https://api.example.com',
username: 'alice',
cryptoAdapter: new NodeCryptoAdapter(),
rtcPeerConnection: wrtc.RTCPeerConnection,
rtcIceCandidate: wrtc.RTCIceCandidate
})
915 lines
29 KiB
TypeScript
915 lines
29 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)
|
|
// WebRTC polyfills for Node.js environments (e.g., wrtc)
|
|
rtcPeerConnection?: typeof RTCPeerConnection
|
|
rtcIceCandidate?: typeof RTCIceCandidate
|
|
}
|
|
|
|
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
|
|
private rtcPeerConnection?: typeof RTCPeerConnection
|
|
private rtcIceCandidate?: typeof RTCIceCandidate
|
|
|
|
// 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,
|
|
rtcPeerConnection?: typeof RTCPeerConnection,
|
|
rtcIceCandidate?: typeof RTCIceCandidate
|
|
) {
|
|
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.rtcPeerConnection = rtcPeerConnection
|
|
this.rtcIceCandidate = rtcIceCandidate
|
|
|
|
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()
|
|
|
|
// Apply WebRTC polyfills to global scope if provided (Node.js environments)
|
|
if (options.rtcPeerConnection) {
|
|
globalThis.RTCPeerConnection = options.rtcPeerConnection as any
|
|
}
|
|
if (options.rtcIceCandidate) {
|
|
globalThis.RTCIceCandidate = options.rtcIceCandidate as any
|
|
}
|
|
|
|
// 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,
|
|
options.rtcPeerConnection,
|
|
options.rtcIceCandidate
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|