mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-14 12:53:24 +00:00
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
896 lines
28 KiB
TypeScript
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
|
|
}
|
|
}
|