From ec19ce50db55be57141eabfe595afa0fcc6ba56c Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 12 Dec 2025 22:26:44 +0100 Subject: [PATCH] Add connectToService() for automatic answering side setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ConnectToServiceOptions and ConnectionContext interfaces - Implement connectToService() method that handles entire answering flow - Automatically discovers/gets service, creates RTCPeerConnection, exchanges answer and ICE candidates - Supports both direct lookup (serviceFqn) and discovery (service) - Returns connection context with pc, dc, serviceFqn, offerId, and peerUsername - Update README with automatic mode examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 39 ++++++++++- src/rondevu.ts | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4070e6b..58246e3 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ await rondevu.startFilling() ### Connecting to a Service (Answerer) +**Automatic mode (recommended):** + ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' @@ -86,13 +88,46 @@ import { Rondevu } from '@xtr-dev/rondevu-client' const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'bob', - iceServers: 'ipv4-turn' // Use same preset as offerer + iceServers: 'ipv4-turn' +}) + +// 2. Connect to service (automatic setup) +const connection = await rondevu.connectToService({ + serviceFqn: 'chat:1.0.0@alice', + onConnection: ({ dc, peerUsername }) => { + console.log('Connected to', peerUsername) + + dc.addEventListener('message', (e) => { + console.log('Received:', e.data) + }) + + dc.addEventListener('open', () => { + dc.send('Hello from Bob!') + }) + } +}) + +// Access connection +connection.dc.send('Another message') +connection.pc.close() // Close when done +``` + +**Manual mode (legacy):** + +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client' + +// 1. Connect to Rondevu +const rondevu = await Rondevu.connect({ + apiUrl: 'https://api.ronde.vu', + username: 'bob', + iceServers: 'ipv4-turn' }) // 2. Get service offer const serviceData = await rondevu.getService('chat:1.0.0@alice') -// 3. Create peer connection (use custom ICE servers if needed) +// 3. Create peer connection const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) diff --git a/src/rondevu.ts b/src/rondevu.ts index aa8b89d..b887dab 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -75,6 +75,22 @@ export interface PublishServiceOptions { ttl?: number // Time-to-live for offers in milliseconds } +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 // Called when data channel opens + rtcConfig?: RTCConfiguration // Optional: override default ICE servers +} + interface ActiveOffer { offerId: string serviceFqn: string @@ -547,6 +563,162 @@ export class Rondevu { this.activeOffers.clear() } + /** + * 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 { + const { serviceFqn, service, username, onConnection, rtcConfig } = options + + // Determine the full service FQN + let fqn: string + if (serviceFqn) { + fqn = serviceFqn + } else if (service && username) { + fqn = `${service}@${username}` + } else if (service) { + // Discovery mode - get random service + console.log(`[Rondevu] Discovering service: ${service}`) + const discovered = await this.discoverService(service) + fqn = discovered.serviceFqn + } else { + throw new Error('Either serviceFqn or service must be provided') + } + + console.log(`[Rondevu] Connecting to service: ${fqn}`) + + // 1. Get service offer + const serviceData = await this.api.getService(fqn) + console.log(`[Rondevu] 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((resolve) => { + pc.ondatachannel = (event) => { + console.log('[Rondevu] Data channel received from offerer') + dc = event.channel + resolve(dc) + } + }) + + // 4. Set up ICE candidate exchange + pc.onicecandidate = async (event) => { + if (event.candidate) { + try { + await this.api.addOfferIceCandidates( + serviceData.serviceFqn, + serviceData.offerId, + [event.candidate.toJSON()] + ) + } catch (err) { + console.error('[Rondevu] Failed to send ICE candidate:', err) + } + } + } + + // 5. Poll for remote ICE candidates + let lastIceTimestamp = 0 + const icePollInterval = setInterval(async () => { + try { + const result = await this.api.getOfferIceCandidates( + serviceData.serviceFqn, + serviceData.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) + } + }, 1000) + + // 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 = () => { + console.log(`[Rondevu] 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') { + console.log('[Rondevu] Data channel already open') + if (onConnection) { + await onConnection(context) + } + } else { + await new Promise((resolve) => { + dc!.addEventListener('open', async () => { + console.log('[Rondevu] Data channel opened') + if (onConnection) { + await onConnection(context) + } + resolve() + }) + }) + } + + return context + } + // ============================================ // Service Discovery // ============================================