1 Commits

Author SHA1 Message Date
db28a133bf 0.18.1 2025-12-14 11:04:21 +01:00
7 changed files with 86 additions and 277 deletions

View File

@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
await rondevu.publishService({ await rondevu.publishService({
service: 'chat:1.0.0', service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (pc) => { offerFactory: async (rtcConfig) => {
// pc is created by Rondevu with ICE handlers already attached const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat') const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => { dc.addEventListener('open', () => {
@@ -64,7 +64,7 @@ await rondevu.publishService({
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
return { dc, offer } return { pc, dc, offer }
} }
}) })

34
package-lock.json generated
View File

@@ -1,16 +1,15 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.18.7", "version": "0.18.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.18.7", "version": "0.18.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0"
"eventemitter3": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1076,18 +1075,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.0.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz",
"integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1", "version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -2003,12 +1990,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2847,15 +2828,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.18.7", "version": "0.18.1",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -41,7 +41,6 @@
"README.md" "README.md"
], ],
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0"
"eventemitter3": "^5.0.1"
} }
} }

View File

@@ -39,7 +39,6 @@ export interface Service {
export interface IceCandidate { export interface IceCandidate {
candidate: RTCIceCandidateInit | null candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
createdAt: number createdAt: number
} }

View File

@@ -3,7 +3,7 @@
* WebRTC peer signaling client * WebRTC peer signaling client
*/ */
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js' export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js' export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js' export { RpcBatcher } from './rpc-batcher.js'
@@ -32,11 +32,7 @@ export type {
ConnectToServiceOptions, ConnectToServiceOptions,
ConnectionContext, ConnectionContext,
OfferContext, OfferContext,
OfferFactory, OfferFactory
ActiveOffer,
FindServiceOptions,
ServiceResult,
PaginatedServiceResult
} from './rondevu.js' } from './rondevu.js'
export type { CryptoAdapter } from './crypto-adapter.js' export type { CryptoAdapter } from './crypto-adapter.js'

View File

@@ -81,11 +81,13 @@ export class NodeCryptoAdapter implements CryptoAdapter {
bytesToBase64(bytes: Uint8Array): string { bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding // Node.js Buffer provides native base64 encoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return Buffer.from(bytes).toString('base64') return Buffer.from(bytes).toString('base64')
} }
base64ToBytes(base64: string): Uint8Array { base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding // Node.js Buffer provides native base64 decoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return new Uint8Array(Buffer.from(base64, 'base64')) return new Uint8Array(Buffer.from(base64, 'base64'))
} }

View File

@@ -1,6 +1,5 @@
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js' import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js' import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'eventemitter3'
// ICE server preset names // ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only' export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -64,19 +63,12 @@ export interface RondevuOptions {
} }
export interface OfferContext { export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel dc?: RTCDataChannel
offer: RTCSessionDescriptionInit offer: RTCSessionDescriptionInit
} }
/** export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>
* Factory function for creating WebRTC offers.
* Rondevu creates the RTCPeerConnection and passes it to the factory,
* allowing ICE candidate handlers to be set up before setLocalDescription() is called.
*
* @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers)
* @returns Promise containing the data channel (optional) and offer SDP
*/
export type OfferFactory = (pc: RTCPeerConnection) => Promise<OfferContext>
export interface PublishServiceOptions { export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
@@ -101,7 +93,7 @@ export interface ConnectToServiceOptions {
rtcConfig?: RTCConfiguration // Optional: override default ICE servers rtcConfig?: RTCConfiguration // Optional: override default ICE servers
} }
export interface ActiveOffer { interface ActiveOffer {
offerId: string offerId: string
serviceFqn: string serviceFqn: string
pc: RTCPeerConnection pc: RTCPeerConnection
@@ -110,73 +102,6 @@ export interface ActiveOffer {
createdAt: number createdAt: number
} }
export interface FindServiceOptions {
mode?: 'direct' | 'random' | 'paginated' // Default: 'direct' if serviceFqn has username, 'random' otherwise
limit?: number // For paginated mode (default: 10)
offset?: number // For paginated mode (default: 0)
}
export interface ServiceResult {
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface PaginatedServiceResult {
services: ServiceResult[]
count: number
limit: number
offset: number
}
/**
* Base error class for Rondevu errors
*/
export class RondevuError extends Error {
constructor(message: string, public context?: Record<string, any>) {
super(message)
this.name = 'RondevuError'
Object.setPrototypeOf(this, RondevuError.prototype)
}
}
/**
* Network-related errors (API calls, connectivity)
*/
export class NetworkError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'NetworkError'
Object.setPrototypeOf(this, NetworkError.prototype)
}
}
/**
* Validation errors (invalid input, malformed data)
*/
export class ValidationError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ValidationError'
Object.setPrototypeOf(this, ValidationError.prototype)
}
}
/**
* WebRTC connection errors (peer connection failures, ICE issues)
*/
export class ConnectionError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ConnectionError'
Object.setPrototypeOf(this, ConnectionError.prototype)
}
}
/** /**
* Rondevu - Complete WebRTC signaling client * Rondevu - Complete WebRTC signaling client
* *
@@ -210,12 +135,12 @@ export class ConnectionError extends RondevuError {
* await rondevu.publishService({ * await rondevu.publishService({
* service: 'chat:2.0.0', * service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers * maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (pc) => { * offerFactory: async (rtcConfig) => {
* // pc is created by Rondevu with ICE handlers already attached * const pc = new RTCPeerConnection(rtcConfig)
* const dc = pc.createDataChannel('chat') * const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer() * const offer = await pc.createOffer()
* await pc.setLocalDescription(offer) * await pc.setLocalDescription(offer)
* return { dc, offer } * return { pc, dc, offer }
* } * }
* }) * })
* *
@@ -231,7 +156,7 @@ export class ConnectionError extends RondevuError {
* rondevu.stopFilling() * rondevu.stopFilling()
* ``` * ```
*/ */
export class Rondevu extends EventEmitter { export class Rondevu {
// Constants // Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
@@ -259,7 +184,6 @@ export class Rondevu extends EventEmitter {
private filling = false private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0 private lastPollTimestamp = 0
private isPolling = false // Guard against concurrent poll execution
private constructor( private constructor(
apiUrl: string, apiUrl: string,
@@ -273,7 +197,6 @@ export class Rondevu extends EventEmitter {
rtcPeerConnection?: typeof RTCPeerConnection, rtcPeerConnection?: typeof RTCPeerConnection,
rtcIceCandidate?: typeof RTCIceCandidate rtcIceCandidate?: typeof RTCIceCandidate
) { ) {
super()
this.apiUrl = apiUrl this.apiUrl = apiUrl
this.username = username this.username = username
this.keypair = keypair this.keypair = keypair
@@ -414,15 +337,15 @@ export class Rondevu extends EventEmitter {
/** /**
* Default offer factory - creates a simple data channel connection * Default offer factory - creates a simple data channel connection
* The RTCPeerConnection is created by Rondevu and passed in
*/ */
private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> { private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default') const dc = pc.createDataChannel('default')
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
return { dc, offer } return { pc, dc, offer }
} }
/** /**
@@ -452,10 +375,6 @@ export class Rondevu extends EventEmitter {
/** /**
* Set up ICE candidate handler to send candidates to the server * Set up ICE candidate handler to send candidates to the server
*
* Note: This is used by connectToService() where the offerId is already known.
* For createOffer(), we use inline ICE handling with early candidate queuing
* since the offerId isn't available until after the factory completes.
*/ */
private setupIceCandidateHandler( private setupIceCandidateHandler(
pc: RTCPeerConnection, pc: RTCPeerConnection,
@@ -472,9 +391,6 @@ export class Rondevu extends EventEmitter {
? event.candidate.toJSON() ? event.candidate.toJSON()
: event.candidate : event.candidate
// Emit local ICE candidate event
this.emit('ice:candidate:local', offerId, candidateData)
await this.api.addOfferIceCandidates( await this.api.addOfferIceCandidates(
serviceFqn, serviceFqn,
offerId, offerId,
@@ -499,20 +415,23 @@ export class Rondevu extends EventEmitter {
iceServers: this.iceServers iceServers: this.iceServers
} }
this.debug('Creating new offer...')
// Create the offer using the factory
// Note: The factory may call setLocalDescription() which triggers ICE gathering
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service // Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}` const serviceFqn = `${this.currentService}@${this.username}`
this.debug('Creating new offer...') // Queue to buffer ICE candidates generated before we have the offerId
// This fixes the race condition where ICE candidates are lost because
// 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early // they're generated before we can set up the handler with the offerId
const pc = new RTCPeerConnection(rtcConfig)
// 2. Set up ICE candidate handler with queuing BEFORE the factory runs
// This ensures we capture all candidates, even those generated immediately
// when setLocalDescription() is called in the factory
const earlyIceCandidates: RTCIceCandidateInit[] = [] const earlyIceCandidates: RTCIceCandidateInit[] = []
let offerId: string | undefined let offerId: string | null = null
// Set up a queuing ICE candidate handler immediately after getting the pc
// This captures any candidates that fire before we have the offerId
pc.onicecandidate = async (event) => { pc.onicecandidate = async (event) => {
if (event.candidate) { if (event.candidate) {
// Handle both browser and Node.js (wrtc) environments // Handle both browser and Node.js (wrtc) environments
@@ -520,11 +439,6 @@ export class Rondevu extends EventEmitter {
? event.candidate.toJSON() ? event.candidate.toJSON()
: event.candidate : event.candidate
// Emit local ICE candidate event
if (offerId) {
this.emit('ice:candidate:local', offerId, candidateData)
}
if (offerId) { if (offerId) {
// We have the offerId, send directly // We have the offerId, send directly
try { try {
@@ -540,22 +454,7 @@ export class Rondevu extends EventEmitter {
} }
} }
// 3. Call the factory with the pc - factory creates data channel and offer // Publish to server
// When factory calls setLocalDescription(), ICE gathering starts and
// candidates are captured by the handler we set up above
let dc: RTCDataChannel | undefined
let offer: RTCSessionDescriptionInit
try {
const factoryResult = await this.offerFactory(pc)
dc = factoryResult.dc
offer = factoryResult.offer
} catch (err) {
// Clean up the connection if factory fails
pc.close()
throw err
}
// 4. Publish to server to get offerId
const result = await this.api.publishService({ const result = await this.api.publishService({
serviceFqn, serviceFqn,
offers: [{ sdp: offer.sdp! }], offers: [{ sdp: offer.sdp! }],
@@ -566,7 +465,7 @@ export class Rondevu extends EventEmitter {
offerId = result.offers[0].offerId offerId = result.offers[0].offerId
// 5. Store active offer // Store active offer
this.activeOffers.set(offerId, { this.activeOffers.set(offerId, {
offerId, offerId,
serviceFqn, serviceFqn,
@@ -577,17 +476,8 @@ export class Rondevu extends EventEmitter {
}) })
this.debug(`Offer created: ${offerId}`) this.debug(`Offer created: ${offerId}`)
this.emit('offer:created', offerId, serviceFqn)
// Set up data channel open handler (offerer side) // Send any queued early ICE candidates
if (dc) {
dc.onopen = () => {
this.debug(`Data channel opened for offer ${offerId}`)
this.emit('connection:opened', offerId, dc)
}
}
// 6. Send any queued early ICE candidates
if (earlyIceCandidates.length > 0) { if (earlyIceCandidates.length > 0) {
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`) this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
try { try {
@@ -597,12 +487,11 @@ export class Rondevu extends EventEmitter {
} }
} }
// 7. Monitor connection state // Monitor connection state
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`) this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.emit('connection:closed', offerId!)
this.activeOffers.delete(offerId!) this.activeOffers.delete(offerId!)
this.fillOffers() // Try to replace failed offer this.fillOffers() // Try to replace failed offer
} }
@@ -635,13 +524,6 @@ export class Rondevu extends EventEmitter {
private async pollInternal(): Promise<void> { private async pollInternal(): Promise<void> {
if (!this.filling) return if (!this.filling) return
// Prevent concurrent poll execution to avoid duplicate answer processing
if (this.isPolling) {
this.debug('Poll already in progress, skipping')
return
}
this.isPolling = true
try { try {
const result = await this.api.poll(this.lastPollTimestamp) const result = await this.api.poll(this.lastPollTimestamp)
@@ -658,7 +540,6 @@ export class Rondevu extends EventEmitter {
activeOffer.answered = true activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt this.lastPollTimestamp = answer.answeredAt
this.emit('offer:answered', answer.offerId, answer.answererId)
// Create replacement offer // Create replacement offer
this.fillOffers() this.fillOffers()
@@ -673,7 +554,6 @@ export class Rondevu extends EventEmitter {
for (const item of answererCandidates) { for (const item of answererCandidates) {
if (item.candidate) { if (item.candidate) {
this.emit('ice:candidate:remote', offerId, item.candidate, item.role)
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate)) await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt) this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
} }
@@ -682,8 +562,6 @@ export class Rondevu extends EventEmitter {
} }
} catch (err) { } catch (err) {
console.error('[Rondevu] Polling error:', err) console.error('[Rondevu] Polling error:', err)
} finally {
this.isPolling = false
} }
} }
@@ -720,7 +598,6 @@ export class Rondevu extends EventEmitter {
stopFilling(): void { stopFilling(): void {
this.debug('Stopping offer filling and polling') this.debug('Stopping offer filling and polling')
this.filling = false this.filling = false
this.isPolling = false // Reset polling guard
// Stop polling // Stop polling
if (this.pollingInterval) { if (this.pollingInterval) {
@@ -738,51 +615,6 @@ export class Rondevu extends EventEmitter {
this.activeOffers.clear() this.activeOffers.clear()
} }
/**
* Get the count of active offers
* @returns Number of active offers
*/
getOfferCount(): number {
return this.activeOffers.size
}
/**
* Check if an offer is currently connected
* @param offerId - The offer ID to check
* @returns True if the offer exists and has been answered
*/
isConnected(offerId: string): boolean {
const offer = this.activeOffers.get(offerId)
return offer ? offer.answered : false
}
/**
* Disconnect all active offers
* Similar to stopFilling() but doesn't stop the polling/filling process
*/
async disconnectAll(): Promise<void> {
this.debug('Disconnecting all offers')
for (const [offerId, offer] of this.activeOffers.entries()) {
this.debug(`Closing offer ${offerId}`)
offer.dc?.close()
offer.pc.close()
}
this.activeOffers.clear()
}
/**
* Get the current service status
* @returns Object with service state information
*/
getServiceStatus(): { active: boolean; offerCount: number; maxOffers: number; filling: boolean } {
return {
active: this.currentService !== null,
offerCount: this.activeOffers.size,
maxOffers: this.maxOffers,
filling: this.filling
}
}
/** /**
* Resolve the full service FQN from various input options * Resolve the full service FQN from various input options
* Supports direct FQN, service+username, or service discovery * Supports direct FQN, service+username, or service discovery
@@ -797,7 +629,7 @@ export class Rondevu extends EventEmitter {
} else if (service) { } else if (service) {
// Discovery mode - get random service // Discovery mode - get random service
this.debug(`Discovering service: ${service}`) this.debug(`Discovering service: ${service}`)
const discovered = await this.findService(service) as ServiceResult const discovered = await this.discoverService(service)
return discovered.serviceFqn return discovered.serviceFqn
} else { } else {
throw new Error('Either serviceFqn or service must be provided') throw new Error('Either serviceFqn or service must be provided')
@@ -824,7 +656,6 @@ export class Rondevu extends EventEmitter {
) )
for (const item of result.candidates) { for (const item of result.candidates) {
if (item.candidate) { if (item.candidate) {
this.emit('ice:candidate:remote', offerId, item.candidate, item.role)
await pc.addIceCandidate(new RTCIceCandidate(item.candidate)) await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt lastIceTimestamp = item.createdAt
} }
@@ -894,7 +725,6 @@ export class Rondevu extends EventEmitter {
pc.ondatachannel = (event) => { pc.ondatachannel = (event) => {
this.debug('Data channel received from offerer') this.debug('Data channel received from offerer')
dc = event.channel dc = event.channel
this.emit('connection:opened', serviceData.offerId, dc)
resolve(dc) resolve(dc)
} }
}) })
@@ -966,45 +796,56 @@ export class Rondevu extends EventEmitter {
// ============================================ // ============================================
/** /**
* Find a service - unified discovery method * Get service by FQN (with username) - Direct lookup
* * Example: chat:1.0.0@alice
* Replaces getService(), discoverService(), and discoverServices() with a single method.
*
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
* @param options - Discovery options
*
* @example
* ```typescript
* // Direct lookup (has username)
* const service = await rondevu.findService('chat:1.0.0@alice')
*
* // Random discovery (no username)
* const service = await rondevu.findService('chat:1.0.0')
*
* // Paginated discovery
* const result = await rondevu.findService('chat:1.0.0', {
* mode: 'paginated',
* limit: 20,
* offset: 0
* })
* ```
*/ */
async findService( async getService(serviceFqn: string): Promise<{
serviceFqn: string, serviceId: string
options?: FindServiceOptions username: string
): Promise<ServiceResult | PaginatedServiceResult> { serviceFqn: string
const { mode, limit = 10, offset = 0 } = options || {} offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
// Auto-detect mode if not specified /**
const hasUsername = serviceFqn.includes('@') * Discover a random available service without knowing the username
const effectiveMode = mode || (hasUsername ? 'direct' : 'random') * 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)
}
if (effectiveMode === 'paginated') { /**
return await this.api.getService(serviceFqn, { limit, offset }) * Discover multiple available services with pagination
} else { * Example: chat:1.0.0 (without @username)
// Both 'direct' and 'random' use the same API call */
return await this.api.getService(serviceFqn) 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 })
} }
// ============================================ // ============================================