mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-14 12:53:24 +00:00
Compare commits
8 Commits
v0.17.0
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0dc9ddad0 | ||
|
|
df231c192d | ||
|
|
62a6cdcb99 | ||
|
|
febe3b7270 | ||
| 83831cae77 | |||
| e954a70aa7 | |||
| 9b879522da | |||
| eb280e3826 |
@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
|
||||
await rondevu.publishService({
|
||||
service: 'chat:1.0.0',
|
||||
maxOffers: 5, // Maintain up to 5 concurrent offers
|
||||
offerFactory: async (rtcConfig) => {
|
||||
const pc = new RTCPeerConnection(rtcConfig)
|
||||
offerFactory: async (pc) => {
|
||||
// pc is created by Rondevu with ICE handlers already attached
|
||||
const dc = pc.createDataChannel('chat')
|
||||
|
||||
dc.addEventListener('open', () => {
|
||||
@@ -64,7 +64,7 @@ await rondevu.publishService({
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
return { pc, dc, offer }
|
||||
return { dc, offer }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "@xtr-dev/rondevu-client",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xtr-dev/rondevu-client",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@xtr-dev/rondevu-client": "^0.9.2"
|
||||
"@noble/ed25519": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -1310,15 +1309,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@xtr-dev/rondevu-client": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz",
|
||||
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/rondevu-client",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
133
src/rondevu.ts
133
src/rondevu.ts
@@ -57,15 +57,25 @@ export interface RondevuOptions {
|
||||
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>
|
||||
/**
|
||||
* 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 {
|
||||
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
|
||||
@@ -132,12 +142,12 @@ interface ActiveOffer {
|
||||
* await rondevu.publishService({
|
||||
* service: 'chat:2.0.0',
|
||||
* maxOffers: 5, // Maintain up to 5 concurrent offers
|
||||
* offerFactory: async (rtcConfig) => {
|
||||
* const pc = new RTCPeerConnection(rtcConfig)
|
||||
* offerFactory: async (pc) => {
|
||||
* // pc is created by Rondevu with ICE handlers already attached
|
||||
* const dc = pc.createDataChannel('chat')
|
||||
* const offer = await pc.createOffer()
|
||||
* await pc.setLocalDescription(offer)
|
||||
* return { pc, dc, offer }
|
||||
* return { dc, offer }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
@@ -167,6 +177,8 @@ export class Rondevu {
|
||||
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
|
||||
@@ -188,7 +200,9 @@ export class Rondevu {
|
||||
iceServers: RTCIceServer[],
|
||||
cryptoAdapter?: CryptoAdapter,
|
||||
batchingOptions?: BatcherOptions | false,
|
||||
debugEnabled = false
|
||||
debugEnabled = false,
|
||||
rtcPeerConnection?: typeof RTCPeerConnection,
|
||||
rtcIceCandidate?: typeof RTCIceCandidate
|
||||
) {
|
||||
this.apiUrl = apiUrl
|
||||
this.username = username
|
||||
@@ -198,6 +212,8 @@ export class Rondevu {
|
||||
this.cryptoAdapter = cryptoAdapter
|
||||
this.batchingOptions = batchingOptions
|
||||
this.debugEnabled = debugEnabled
|
||||
this.rtcPeerConnection = rtcPeerConnection
|
||||
this.rtcIceCandidate = rtcIceCandidate
|
||||
|
||||
this.debug('Instance created:', {
|
||||
username: this.username,
|
||||
@@ -230,6 +246,14 @@ export class Rondevu {
|
||||
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') {
|
||||
@@ -277,7 +301,9 @@ export class Rondevu {
|
||||
iceServers,
|
||||
options.cryptoAdapter,
|
||||
options.batching,
|
||||
options.debug || false
|
||||
options.debug || false,
|
||||
options.rtcPeerConnection,
|
||||
options.rtcIceCandidate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -318,15 +344,15 @@ export class Rondevu {
|
||||
|
||||
/**
|
||||
* Default offer factory - creates a simple data channel connection
|
||||
* The RTCPeerConnection is created by Rondevu and passed in
|
||||
*/
|
||||
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
|
||||
const pc = new RTCPeerConnection(rtcConfig)
|
||||
private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> {
|
||||
const dc = pc.createDataChannel('default')
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
|
||||
return { pc, dc, offer }
|
||||
return { dc, offer }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,6 +382,10 @@ export class Rondevu {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
pc: RTCPeerConnection,
|
||||
@@ -365,10 +395,17 @@ export class Rondevu {
|
||||
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,
|
||||
[event.candidate.toJSON()]
|
||||
[candidateData]
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[Rondevu] Failed to send ICE candidate:', err)
|
||||
@@ -389,15 +426,58 @@ export class Rondevu {
|
||||
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
|
||||
this.debug('Creating new offer...')
|
||||
|
||||
// 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
|
||||
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[] = []
|
||||
let offerId: string | undefined
|
||||
|
||||
pc.onicecandidate = async (event) => {
|
||||
if (event.candidate) {
|
||||
// Handle both browser and Node.js (wrtc) environments
|
||||
const candidateData = typeof event.candidate.toJSON === 'function'
|
||||
? event.candidate.toJSON()
|
||||
: event.candidate
|
||||
|
||||
if (offerId) {
|
||||
// We have the offerId, send directly
|
||||
try {
|
||||
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData])
|
||||
} catch (err) {
|
||||
console.error('[Rondevu] Failed to send ICE candidate:', err)
|
||||
}
|
||||
} else {
|
||||
// Queue for later - we don't have the offerId yet
|
||||
this.debug('Queuing early ICE candidate')
|
||||
earlyIceCandidates.push(candidateData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Call the factory with the pc - factory creates data channel and offer
|
||||
// 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({
|
||||
serviceFqn,
|
||||
offers: [{ sdp: offer.sdp! }],
|
||||
@@ -406,9 +486,9 @@ export class Rondevu {
|
||||
message: '',
|
||||
})
|
||||
|
||||
const offerId = result.offers[0].offerId
|
||||
offerId = result.offers[0].offerId
|
||||
|
||||
// Store active offer
|
||||
// 5. Store active offer
|
||||
this.activeOffers.set(offerId, {
|
||||
offerId,
|
||||
serviceFqn,
|
||||
@@ -420,15 +500,22 @@ export class Rondevu {
|
||||
|
||||
this.debug(`Offer created: ${offerId}`)
|
||||
|
||||
// Set up ICE candidate handler
|
||||
this.setupIceCandidateHandler(pc, serviceFqn, offerId)
|
||||
// 6. Send any queued early ICE candidates
|
||||
if (earlyIceCandidates.length > 0) {
|
||||
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
|
||||
try {
|
||||
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates)
|
||||
} catch (err) {
|
||||
console.error('[Rondevu] Failed to send early ICE candidates:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor connection state
|
||||
// 7. 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.activeOffers.delete(offerId!)
|
||||
this.fillOffers() // Try to replace failed offer
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user