mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-13 04:13:25 +00:00
Add ICE server preset support and update to Rondevu.connect()
- Add 4 ICE server presets: ipv4-turn, hostname-turns, google-stun, relay-only - Update Rondevu.connect() to accept preset string or custom RTCIceServer array - Support both automatic (maxOffers) and manual (offers array) modes in publishService() - Rename internal poll() to pollInternal() to avoid conflict with public API - Update README examples to use presets and proper offer factory pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
96
README.md
96
README.md
@@ -38,76 +38,43 @@ npm install @xtr-dev/rondevu-client
|
|||||||
```typescript
|
```typescript
|
||||||
import { Rondevu } from '@xtr-dev/rondevu-client'
|
import { Rondevu } from '@xtr-dev/rondevu-client'
|
||||||
|
|
||||||
// 1. Connect to Rondevu (generates keypair, username auto-claimed on first request)
|
// 1. Connect to Rondevu with ICE server preset (generates keypair, username auto-claimed on first request)
|
||||||
const rondevu = await Rondevu.connect({
|
const rondevu = await Rondevu.connect({
|
||||||
apiUrl: 'https://api.ronde.vu',
|
apiUrl: 'https://api.ronde.vu',
|
||||||
username: 'alice' // Or omit for anonymous username
|
username: 'alice', // Or omit for anonymous username
|
||||||
|
iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Create WebRTC offer
|
// 2. Publish service with custom offer factory for event handling
|
||||||
const pc = new RTCPeerConnection({
|
await rondevu.publishService({
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const dc = pc.createDataChannel('chat')
|
|
||||||
|
|
||||||
const offer = await pc.createOffer()
|
|
||||||
await pc.setLocalDescription(offer)
|
|
||||||
|
|
||||||
// 4. Publish service
|
|
||||||
const service = await rondevu.publishService({
|
|
||||||
service: 'chat:1.0.0',
|
service: 'chat:1.0.0',
|
||||||
offers: [{ sdp: offer.sdp }],
|
maxOffers: 5, // Maintain up to 5 concurrent offers
|
||||||
|
offerFactory: async (rtcConfig) => {
|
||||||
|
const pc = new RTCPeerConnection(rtcConfig)
|
||||||
|
const dc = pc.createDataChannel('chat')
|
||||||
|
|
||||||
|
// Set up event listeners during creation
|
||||||
|
dc.addEventListener('open', () => {
|
||||||
|
console.log('Connection opened!')
|
||||||
|
dc.send('Hello from Alice!')
|
||||||
|
})
|
||||||
|
|
||||||
|
dc.addEventListener('message', (e) => {
|
||||||
|
console.log('Received message:', e.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
return { pc, dc, offer }
|
||||||
|
},
|
||||||
ttl: 300000
|
ttl: 300000
|
||||||
})
|
})
|
||||||
|
|
||||||
const offerId = service.offers[0].offerId
|
// 3. Start accepting connections (auto-fills offers and polls)
|
||||||
|
await rondevu.startFilling()
|
||||||
|
|
||||||
// 5. Poll for answer and ICE candidates (combined)
|
// 4. Stop when done
|
||||||
let lastPollTimestamp = 0
|
// rondevu.stopFilling()
|
||||||
const pollInterval = setInterval(async () => {
|
|
||||||
const result = await rondevu.pollOffers(lastPollTimestamp)
|
|
||||||
|
|
||||||
// Check for answer
|
|
||||||
if (result.answers.length > 0) {
|
|
||||||
const answer = result.answers.find(a => a.offerId === offerId)
|
|
||||||
if (answer) {
|
|
||||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
|
|
||||||
lastPollTimestamp = answer.answeredAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process ICE candidates
|
|
||||||
if (result.iceCandidates[offerId]) {
|
|
||||||
const candidates = result.iceCandidates[offerId]
|
|
||||||
.filter(c => c.role === 'answerer') // Only answerer's candidates
|
|
||||||
|
|
||||||
for (const item of candidates) {
|
|
||||||
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
|
|
||||||
lastPollTimestamp = Math.max(lastPollTimestamp, item.createdAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
// 6. Send ICE candidates
|
|
||||||
pc.onicecandidate = (event) => {
|
|
||||||
if (event.candidate) {
|
|
||||||
rondevu.addOfferIceCandidates(
|
|
||||||
'chat:1.0.0@alice',
|
|
||||||
offerId,
|
|
||||||
[event.candidate.toJSON()]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Handle messages
|
|
||||||
dc.onmessage = (event) => {
|
|
||||||
console.log('Received:', event.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.onopen = () => {
|
|
||||||
dc.send('Hello from Alice!')
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Connecting to a Service (Answerer)
|
### Connecting to a Service (Answerer)
|
||||||
@@ -115,16 +82,17 @@ dc.onopen = () => {
|
|||||||
```typescript
|
```typescript
|
||||||
import { Rondevu } from '@xtr-dev/rondevu-client'
|
import { Rondevu } from '@xtr-dev/rondevu-client'
|
||||||
|
|
||||||
// 1. Connect to Rondevu
|
// 1. Connect to Rondevu with ICE server preset
|
||||||
const rondevu = await Rondevu.connect({
|
const rondevu = await Rondevu.connect({
|
||||||
apiUrl: 'https://api.ronde.vu',
|
apiUrl: 'https://api.ronde.vu',
|
||||||
username: 'bob'
|
username: 'bob',
|
||||||
|
iceServers: 'ipv4-turn' // Use same preset as offerer
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Get service offer
|
// 2. Get service offer
|
||||||
const serviceData = await rondevu.getService('chat:1.0.0@alice')
|
const serviceData = await rondevu.getService('chat:1.0.0@alice')
|
||||||
|
|
||||||
// 3. Create peer connection
|
// 3. Create peer connection (use custom ICE servers if needed)
|
||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
})
|
})
|
||||||
|
|||||||
412
src/rondevu.ts
412
src/rondevu.ts
@@ -1,18 +1,87 @@
|
|||||||
import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate, BatcherOptions } from './api.js'
|
import { RondevuAPI, Keypair, Service, ServiceRequest, IceCandidate, BatcherOptions } from './api.js'
|
||||||
import { CryptoAdapter } from './crypto-adapter.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 {
|
export interface RondevuOptions {
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
username?: string // Optional, will generate anonymous if not provided
|
username?: string // Optional, will generate anonymous if not provided
|
||||||
keypair?: Keypair // Optional, will generate if not provided
|
keypair?: Keypair // Optional, will generate if not provided
|
||||||
cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter
|
cryptoAdapter?: CryptoAdapter // Optional, defaults to WebCryptoAdapter
|
||||||
batching?: BatcherOptions | false // Optional, defaults to enabled with default options
|
batching?: BatcherOptions | false // Optional, defaults to enabled with default options
|
||||||
|
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OfferContext {
|
||||||
|
pc: RTCPeerConnection
|
||||||
|
dc?: RTCDataChannel
|
||||||
|
offer: RTCSessionDescriptionInit
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfferFactory = (rtcConfig: RTCConfiguration) => 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
|
||||||
offers: Array<{ sdp: string }>
|
serviceFqn?: string // Full service FQN (legacy, use 'service' instead)
|
||||||
ttl?: number
|
maxOffers?: number // Maximum number of concurrent offers to maintain (automatic mode)
|
||||||
|
offers?: Array<{ sdp: string }> // Manual offers array (legacy mode)
|
||||||
|
offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel)
|
||||||
|
ttl?: number // Time-to-live for offers in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveOffer {
|
||||||
|
offerId: string
|
||||||
|
serviceFqn: string
|
||||||
|
pc: RTCPeerConnection
|
||||||
|
dc?: RTCDataChannel
|
||||||
|
answered: boolean
|
||||||
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,24 +96,46 @@ export interface PublishServiceOptions {
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Create and initialize Rondevu instance
|
* // Create and initialize Rondevu instance with preset ICE servers
|
||||||
* const rondevu = await Rondevu.connect({
|
* const rondevu = await Rondevu.connect({
|
||||||
* apiUrl: 'https://signal.example.com',
|
* apiUrl: 'https://signal.example.com',
|
||||||
* username: 'alice',
|
* username: 'alice',
|
||||||
|
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Publish a service (username auto-claimed on first publish)
|
* // Or use custom ICE servers
|
||||||
* const publishedService = await rondevu.publishService({
|
* const rondevu2 = await Rondevu.connect({
|
||||||
* service: 'chat:1.0.0',
|
* apiUrl: 'https://signal.example.com',
|
||||||
* offers: [{ sdp: offerSdp }],
|
* username: 'bob',
|
||||||
* ttl: 300000,
|
* iceServers: [
|
||||||
|
* { urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
|
||||||
|
* ]
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Discover a service
|
* // Publish a service with automatic offer management
|
||||||
* const service = await rondevu.getService('chat:1.0.0@bob')
|
* 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 }
|
||||||
|
* }
|
||||||
|
* })
|
||||||
*
|
*
|
||||||
* // Post answer
|
* // Start accepting connections (auto-fills offers and polls)
|
||||||
* await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
|
* 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 {
|
export class Rondevu {
|
||||||
@@ -55,12 +146,26 @@ export class Rondevu {
|
|||||||
private usernameClaimed = false
|
private usernameClaimed = false
|
||||||
private cryptoAdapter?: CryptoAdapter
|
private cryptoAdapter?: CryptoAdapter
|
||||||
private batchingOptions?: BatcherOptions | false
|
private batchingOptions?: BatcherOptions | false
|
||||||
|
private iceServers: RTCIceServer[]
|
||||||
|
|
||||||
|
// Service management
|
||||||
|
private currentService: string | null = null
|
||||||
|
private maxOffers = 0
|
||||||
|
private offerFactory: OfferFactory | null = null
|
||||||
|
private ttl = 300000 // 5 minutes default
|
||||||
|
private activeOffers = new Map<string, ActiveOffer>()
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
private filling = false
|
||||||
|
private pollingInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
private lastPollTimestamp = 0
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
keypair: Keypair,
|
keypair: Keypair,
|
||||||
api: RondevuAPI,
|
api: RondevuAPI,
|
||||||
|
iceServers: RTCIceServer[],
|
||||||
cryptoAdapter?: CryptoAdapter,
|
cryptoAdapter?: CryptoAdapter,
|
||||||
batchingOptions?: BatcherOptions | false
|
batchingOptions?: BatcherOptions | false
|
||||||
) {
|
) {
|
||||||
@@ -68,12 +173,14 @@ export class Rondevu {
|
|||||||
this.username = username
|
this.username = username
|
||||||
this.keypair = keypair
|
this.keypair = keypair
|
||||||
this.api = api
|
this.api = api
|
||||||
|
this.iceServers = iceServers
|
||||||
this.cryptoAdapter = cryptoAdapter
|
this.cryptoAdapter = cryptoAdapter
|
||||||
this.batchingOptions = batchingOptions
|
this.batchingOptions = batchingOptions
|
||||||
|
|
||||||
console.log('[Rondevu] Instance created:', {
|
console.log('[Rondevu] Instance created:', {
|
||||||
username: this.username,
|
username: this.username,
|
||||||
publicKey: this.keypair.publicKey,
|
publicKey: this.keypair.publicKey,
|
||||||
|
hasIceServers: iceServers.length > 0,
|
||||||
batchingEnabled: batchingOptions !== false
|
batchingEnabled: batchingOptions !== false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -92,9 +199,20 @@ export class Rondevu {
|
|||||||
static async connect(options: RondevuOptions): Promise<Rondevu> {
|
static async connect(options: RondevuOptions): Promise<Rondevu> {
|
||||||
const username = options.username || Rondevu.generateAnonymousUsername()
|
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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Rondevu] Connecting:', {
|
console.log('[Rondevu] Connecting:', {
|
||||||
username,
|
username,
|
||||||
hasKeypair: !!options.keypair,
|
hasKeypair: !!options.keypair,
|
||||||
|
iceServers: iceServers.length,
|
||||||
batchingEnabled: options.batching !== false
|
batchingEnabled: options.batching !== false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,6 +241,7 @@ export class Rondevu {
|
|||||||
username,
|
username,
|
||||||
keypair,
|
keypair,
|
||||||
api,
|
api,
|
||||||
|
iceServers,
|
||||||
options.cryptoAdapter,
|
options.cryptoAdapter,
|
||||||
options.batching
|
options.batching
|
||||||
)
|
)
|
||||||
@@ -164,29 +283,268 @@ export class Rondevu {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a service with automatic signature generation
|
* Default offer factory - creates a simple data channel connection
|
||||||
* Username will be automatically claimed on first publish if not already claimed
|
|
||||||
*/
|
*/
|
||||||
async publishService(options: PublishServiceOptions): Promise<Service> {
|
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
|
||||||
const { service, offers, ttl } = options
|
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
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* 1. Automatic offer management (recommended):
|
||||||
|
* Pass maxOffers and optionally offerFactory
|
||||||
|
* Call startFilling() to begin accepting connections
|
||||||
|
*
|
||||||
|
* 2. Manual mode (legacy):
|
||||||
|
* Pass offers array with pre-created SDP offers
|
||||||
|
* Returns published service data
|
||||||
|
*
|
||||||
|
* @example Automatic mode:
|
||||||
|
* ```typescript
|
||||||
|
* await rondevu.publishService({
|
||||||
|
* service: 'chat:2.0.0',
|
||||||
|
* maxOffers: 5
|
||||||
|
* })
|
||||||
|
* await rondevu.startFilling()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Manual mode (legacy):
|
||||||
|
* ```typescript
|
||||||
|
* const published = await rondevu.publishService({
|
||||||
|
* serviceFqn: 'chat:2.0.0@alice',
|
||||||
|
* offers: [{ sdp: offerSdp }]
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async publishService(options: PublishServiceOptions): Promise<any> {
|
||||||
|
const { service, serviceFqn, maxOffers, offers, offerFactory, ttl } = options
|
||||||
|
|
||||||
|
// Manual mode (legacy) - publish pre-created offers
|
||||||
|
if (offers && offers.length > 0) {
|
||||||
|
const fqn = serviceFqn || `${service}@${this.username}`
|
||||||
|
const result = await this.api.publishService({
|
||||||
|
serviceFqn: fqn,
|
||||||
|
offers,
|
||||||
|
ttl: ttl || 300000,
|
||||||
|
signature: '',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
this.usernameClaimed = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatic mode - store configuration for startFilling()
|
||||||
|
if (maxOffers !== undefined) {
|
||||||
|
const svc = service || serviceFqn?.split('@')[0]
|
||||||
|
if (!svc) {
|
||||||
|
throw new Error('Either service or serviceFqn must be provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentService = svc
|
||||||
|
this.maxOffers = maxOffers
|
||||||
|
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this)
|
||||||
|
this.ttl = ttl || 300000
|
||||||
|
|
||||||
|
console.log(`[Rondevu] Publishing service: ${svc} with maxOffers: ${maxOffers}`)
|
||||||
|
this.usernameClaimed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Either maxOffers (automatic mode) or offers array (manual mode) must be provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Rondevu] Creating new offer...')
|
||||||
|
|
||||||
|
// Create the offer using the factory
|
||||||
|
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
|
||||||
|
|
||||||
// Auto-append username to service
|
// Auto-append username to service
|
||||||
const serviceFqn = `${service}@${this.username}`
|
const serviceFqn = `${this.currentService}@${this.username}`
|
||||||
|
|
||||||
// Publish to server (server will auto-claim username if needed)
|
// Publish to server
|
||||||
// Note: signature and message are generated by the API layer
|
|
||||||
const result = await this.api.publishService({
|
const result = await this.api.publishService({
|
||||||
serviceFqn,
|
serviceFqn,
|
||||||
offers,
|
offers: [{ sdp: offer.sdp! }],
|
||||||
ttl,
|
ttl: this.ttl,
|
||||||
signature: '', // Not used, generated by API layer
|
signature: '',
|
||||||
message: '', // Not used, generated by API layer
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mark username as claimed after successful publish
|
const offerId = result.offers[0].offerId
|
||||||
this.usernameClaimed = true
|
|
||||||
|
|
||||||
return result
|
// Store active offer
|
||||||
|
this.activeOffers.set(offerId, {
|
||||||
|
offerId,
|
||||||
|
serviceFqn,
|
||||||
|
pc,
|
||||||
|
dc,
|
||||||
|
answered: false,
|
||||||
|
createdAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Rondevu] Offer created: ${offerId}`)
|
||||||
|
|
||||||
|
// Set up ICE candidate handler
|
||||||
|
pc.onicecandidate = async (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
try {
|
||||||
|
await this.api.addOfferIceCandidates(
|
||||||
|
serviceFqn,
|
||||||
|
offerId,
|
||||||
|
[event.candidate.toJSON()]
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Rondevu] Failed to send ICE candidate:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor connection state
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
console.log(`[Rondevu] 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
|
||||||
|
|
||||||
|
console.log(`[Rondevu] 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) {
|
||||||
|
console.log(`[Rondevu] 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) {
|
||||||
|
console.log('[Rondevu] Already filling')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.currentService) {
|
||||||
|
throw new Error('No service published. Call publishService() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Rondevu] Starting offer filling and polling')
|
||||||
|
this.filling = true
|
||||||
|
|
||||||
|
// Fill initial offers
|
||||||
|
await this.fillOffers()
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
this.pollingInterval = setInterval(() => {
|
||||||
|
this.pollInternal()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop filling offers and polling
|
||||||
|
* Closes all active peer connections
|
||||||
|
*/
|
||||||
|
stopFilling(): void {
|
||||||
|
console.log('[Rondevu] 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()) {
|
||||||
|
console.log(`[Rondevu] Closing offer ${offerId}`)
|
||||||
|
offer.dc?.close()
|
||||||
|
offer.pc.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeOffers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user