diff --git a/README.md b/README.md index 8263251..f153d80 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,58 @@ -# @xtr-dev/rondevu-client +# Rondevu -TypeScript client for interacting with the Rondevu peer signaling and discovery server. Provides a simple, type-safe API for WebRTC peer discovery and connection establishment. +🎯 Meet WebRTC peers by topic, by peer ID, or by connection ID. -## Installation +## @xtr-dev/rondevu-client + +Rondevu HTTP and WebRTC client, for simple peer discovery and connection. + +### Install ```bash npm install @xtr-dev/rondevu-client ``` -## Usage - -### Basic Setup +### Usage ```typescript -import { RondevuClient } from '@xtr-dev/rondevu-client'; +import { Rondevu } from '@xtr-dev/rondevu-client'; -const client = new RondevuClient({ - baseUrl: 'https://rondevu.example.com', - // Optional: custom origin for session isolation - origin: 'https://myapp.com' +const rdv = new Rondevu({ baseUrl: 'https://server.com' }); + +// Connect by topic +const conn = await rdv.join('room'); + +// Or connect by ID +const conn = await rdv.connect('meeting-123'); + +// Use the connection +conn.on('connect', () => { + const channel = conn.dataChannel('chat'); + channel.send('Hello!'); }); ``` -### Peer Discovery Flow +### API -#### 1. List Available Topics +**Main Methods:** +- `rdv.join(topic)` - Auto-connect to first peer in topic +- `rdv.join(topic, {filter})` - Connect to specific peer by ID +- `rdv.create(id, topic)` - Create connection for others to join +- `rdv.connect(id)` - Join connection by ID -```typescript -// Get all topics with peer counts -const { topics, pagination } = await client.listTopics(); +**Connection Events:** +- `connect` - Connection established +- `disconnect` - Connection closed +- `datachannel` - Remote peer created data channel +- `stream` - Remote media stream received +- `error` - Error occurred -topics.forEach(topic => { - console.log(`${topic.topic}: ${topic.count} peers available`); -}); -``` +**Connection Methods:** +- `conn.dataChannel(label)` - Get or create data channel +- `conn.addStream(stream)` - Add media stream +- `conn.getPeerConnection()` - Get underlying RTCPeerConnection +- `conn.close()` - Close connection -#### 2. Create an Offer (Peer A) - -```typescript -// Announce availability in a topic -const { code } = await client.createOffer('my-room', { - info: 'peer-A-unique-id', - offer: webrtcOfferData -}); - -console.log('Session code:', code); -``` - -#### 3. Discover Peers (Peer B) - -```typescript -// Find available peers in a topic -const { sessions } = await client.listSessions('my-room'); - -// Filter out your own sessions -const otherPeers = sessions.filter(s => s.info !== 'my-peer-id'); - -if (otherPeers.length > 0) { - const peer = otherPeers[0]; - console.log('Found peer:', peer.info); -} -``` - -#### 4. Send Answer (Peer B) - -```typescript -// Connect to a peer by answering their offer -await client.sendAnswer({ - code: peer.code, - answer: webrtcAnswerData, - side: 'answerer' -}); -``` - -#### 5. Poll for Data (Both Peers) - -```typescript -// Offerer polls for answer -const offererData = await client.poll(code, 'offerer'); -if (offererData.answer) { - console.log('Received answer from peer'); -} - -// Answerer polls for offer details -const answererData = await client.poll(code, 'answerer'); -console.log('Offer candidates:', answererData.offerCandidates); -``` - -#### 6. Exchange ICE Candidates - -```typescript -// Send additional signaling data -await client.sendAnswer({ - code: sessionCode, - candidate: iceCandidate, - side: 'offerer' // or 'answerer' -}); -``` - -### Health Check - -```typescript -const health = await client.health(); -console.log('Server status:', health.status); -console.log('Timestamp:', health.timestamp); -``` - -## API Reference - -### `RondevuClient` - -#### Constructor - -```typescript -new RondevuClient(options: RondevuClientOptions) -``` - -**Options:** -- `baseUrl` (string, required): Base URL of the Rondevu server -- `origin` (string, optional): Origin header for session isolation (defaults to baseUrl origin) -- `fetch` (function, optional): Custom fetch implementation (for Node.js) - -#### Methods - -##### `listTopics(page?, limit?)` - -Lists all topics with peer counts. - -**Parameters:** -- `page` (number, optional): Page number, default 1 -- `limit` (number, optional): Results per page, default 100, max 1000 - -**Returns:** `Promise` - -##### `listSessions(topic)` - -Discovers available peers for a given topic. - -**Parameters:** -- `topic` (string): Topic identifier - -**Returns:** `Promise` - -##### `createOffer(topic, request)` - -Announces peer availability and creates a new session. - -**Parameters:** -- `topic` (string): Topic identifier (max 256 characters) -- `request` (CreateOfferRequest): - - `info` (string): Peer identifier/metadata (max 1024 characters) - - `offer` (string): WebRTC signaling data - -**Returns:** `Promise` - -##### `sendAnswer(request)` - -Sends an answer or candidate to an existing session. - -**Parameters:** -- `request` (AnswerRequest): - - `code` (string): Session UUID - - `answer` (string, optional): Answer signaling data - - `candidate` (string, optional): ICE candidate data - - `side` ('offerer' | 'answerer'): Which peer is sending - -**Returns:** `Promise` - -##### `poll(code, side)` - -Polls for session data from the other peer. - -**Parameters:** -- `code` (string): Session UUID -- `side` ('offerer' | 'answerer'): Which side is polling - -**Returns:** `Promise` - -##### `health()` - -Checks server health. - -**Returns:** `Promise` - -## TypeScript Types - -All types are exported from the main package: - -```typescript -import { - RondevuClient, - Session, - TopicInfo, - CreateOfferRequest, - AnswerRequest, - PollRequest, - Side, - // ... and more -} from '@xtr-dev/rondevu-client'; -``` - -## Node.js Usage - -For Node.js environments (v18+), the built-in fetch is used automatically. For older Node.js versions, provide a fetch implementation: - -```typescript -import fetch from 'node-fetch'; -import { RondevuClient } from '@xtr-dev/rondevu-client'; - -const client = new RondevuClient({ - baseUrl: 'https://rondevu.example.com', - fetch: fetch as any -}); -``` - -## Error Handling - -All API methods throw errors with descriptive messages: - -```typescript -try { - await client.createOffer('my-room', { - info: 'peer-id', - offer: data - }); -} catch (error) { - console.error('Failed to create offer:', error.message); -} -``` - -## License +### License MIT diff --git a/package.json b/package.json index 1c21393..a0474e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.0.2", + "version": "0.0.3", "description": "TypeScript client for Rondevu peer signaling and discovery server", + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/src/client.ts b/src/client.ts index 6c8f5f2..ee5aed1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,7 +99,7 @@ export class RondevuClient { * ```typescript * const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const { sessions } = await client.listSessions('my-room'); - * const otherPeers = sessions.filter(s => s.info !== myPeerId); + * const otherPeers = sessions.filter(s => s.peerId !== myPeerId); * ``` */ async listSessions(topic: string): Promise { @@ -111,15 +111,15 @@ export class RondevuClient { /** * Announces peer availability and creates a new session * - * @param topic - Topic identifier for grouping peers (max 256 characters) - * @param request - Offer details including peer info and signaling data + * @param topic - Topic identifier for grouping peers (max 1024 characters) + * @param request - Offer details including peer ID and signaling data * @returns Unique session code (UUID) * * @example * ```typescript * const client = new RondevuClient({ baseUrl: 'https://example.com' }); * const { code } = await client.createOffer('my-room', { - * info: 'peer-123', + * peerId: 'peer-123', * offer: signalingData * }); * console.log('Session code:', code); diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..6c1175e --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,310 @@ +import { EventEmitter } from './event-emitter'; +import { RondevuClient } from './client'; +import { RondevuConnectionParams } from './types'; + +/** + * Represents a WebRTC connection with automatic signaling and ICE exchange + */ +export class RondevuConnection extends EventEmitter { + readonly id: string; + readonly topic: string; + readonly role: 'offerer' | 'answerer'; + readonly remotePeerId: string; + + private pc: RTCPeerConnection; + private client: RondevuClient; + private localPeerId: string; + private dataChannels: Map; + private pollingInterval?: ReturnType; + private pollingIntervalMs: number; + private connectionTimeoutMs: number; + private connectionTimer?: ReturnType; + private isPolling: boolean = false; + private isClosed: boolean = false; + + constructor(params: RondevuConnectionParams, client: RondevuClient) { + super(); + this.id = params.id; + this.topic = params.topic; + this.role = params.role; + this.pc = params.pc; + this.localPeerId = params.localPeerId; + this.remotePeerId = params.remotePeerId; + this.client = client; + this.dataChannels = new Map(); + this.pollingIntervalMs = params.pollingInterval; + this.connectionTimeoutMs = params.connectionTimeout; + + this.setupEventHandlers(); + this.startConnectionTimeout(); + } + + /** + * Setup RTCPeerConnection event handlers + */ + private setupEventHandlers(): void { + // ICE candidate gathering + this.pc.onicecandidate = (event) => { + if (event.candidate && !this.isClosed) { + this.sendIceCandidate(event.candidate).catch((err) => { + this.emit('error', new Error(`Failed to send ICE candidate: ${err.message}`)); + }); + } + }; + + // Connection state changes + this.pc.onconnectionstatechange = () => { + this.handleConnectionStateChange(); + }; + + // Remote data channels + this.pc.ondatachannel = (event) => { + this.handleRemoteDataChannel(event.channel); + }; + + // Remote media streams + this.pc.ontrack = (event) => { + if (event.streams && event.streams[0]) { + this.emit('stream', event.streams[0]); + } + }; + + // ICE connection state changes + this.pc.oniceconnectionstatechange = () => { + const state = this.pc.iceConnectionState; + + if (state === 'failed' || state === 'closed') { + this.emit('error', new Error(`ICE connection ${state}`)); + if (state === 'failed') { + this.close(); + } + } + }; + } + + /** + * Handle RTCPeerConnection state changes + */ + private handleConnectionStateChange(): void { + const state = this.pc.connectionState; + + switch (state) { + case 'connected': + this.clearConnectionTimeout(); + this.stopPolling(); + this.emit('connect'); + break; + + case 'disconnected': + this.emit('disconnect'); + break; + + case 'failed': + this.emit('error', new Error('Connection failed')); + this.close(); + break; + + case 'closed': + this.emit('disconnect'); + break; + } + } + + /** + * Send an ICE candidate to the remote peer via signaling server + */ + private async sendIceCandidate(candidate: RTCIceCandidate): Promise { + try { + await this.client.sendAnswer({ + code: this.id, + candidate: JSON.stringify(candidate.toJSON()), + side: this.role, + }); + } catch (err: any) { + throw new Error(`Failed to send ICE candidate: ${err.message}`); + } + } + + /** + * Start polling for remote session data (answer/candidates) + */ + startPolling(): void { + if (this.isPolling || this.isClosed) { + return; + } + + this.isPolling = true; + + // Poll immediately + this.poll().catch((err) => { + this.emit('error', new Error(`Poll error: ${err.message}`)); + }); + + // Set up interval polling + this.pollingInterval = setInterval(() => { + this.poll().catch((err) => { + this.emit('error', new Error(`Poll error: ${err.message}`)); + }); + }, this.pollingIntervalMs); + } + + /** + * Stop polling + */ + private stopPolling(): void { + this.isPolling = false; + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + } + } + + /** + * Poll the signaling server for remote data + */ + private async poll(): Promise { + if (this.isClosed) { + this.stopPolling(); + return; + } + + try { + const response = await this.client.poll(this.id, this.role); + + if (this.role === 'offerer') { + const offererResponse = response as { answer: string | null; answerCandidates: string[] }; + + // Apply answer if received and not yet applied + if (offererResponse.answer && !this.pc.currentRemoteDescription) { + await this.pc.setRemoteDescription({ + type: 'answer', + sdp: offererResponse.answer, + }); + } + + // Apply ICE candidates + if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) { + for (const candidateStr of offererResponse.answerCandidates) { + try { + const candidate = JSON.parse(candidateStr); + await this.pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.warn('Failed to add ICE candidate:', err); + } + } + } + } else { + // Answerer role + const answererResponse = response as { offer: string; offerCandidates: string[] }; + + // Apply ICE candidates from offerer + if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) { + for (const candidateStr of answererResponse.offerCandidates) { + try { + const candidate = JSON.parse(candidateStr); + await this.pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.warn('Failed to add ICE candidate:', err); + } + } + } + } + } catch (err: any) { + // Session not found or expired + if (err.message.includes('404') || err.message.includes('not found')) { + this.emit('error', new Error('Session not found or expired')); + this.close(); + } + throw err; + } + } + + /** + * Handle remotely created data channel + */ + private handleRemoteDataChannel(channel: RTCDataChannel): void { + this.dataChannels.set(channel.label, channel); + this.emit('datachannel', channel); + } + + /** + * Get or create a data channel + */ + dataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel { + let channel = this.dataChannels.get(label); + + if (!channel) { + channel = this.pc.createDataChannel(label, options); + this.dataChannels.set(label, channel); + } + + return channel; + } + + /** + * Add a local media stream to the connection + */ + addStream(stream: MediaStream): void { + stream.getTracks().forEach(track => { + this.pc.addTrack(track, stream); + }); + } + + /** + * Get the underlying RTCPeerConnection for advanced usage + */ + getPeerConnection(): RTCPeerConnection { + return this.pc; + } + + /** + * Start connection timeout + */ + private startConnectionTimeout(): void { + this.connectionTimer = setTimeout(() => { + if (this.pc.connectionState !== 'connected') { + this.emit('error', new Error('Connection timeout')); + this.close(); + } + }, this.connectionTimeoutMs); + } + + /** + * Clear connection timeout + */ + private clearConnectionTimeout(): void { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + this.connectionTimer = undefined; + } + } + + /** + * Close the connection and cleanup resources + */ + close(): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + + this.stopPolling(); + this.clearConnectionTimeout(); + + // Close all data channels + this.dataChannels.forEach(dc => { + if (dc.readyState === 'open' || dc.readyState === 'connecting') { + dc.close(); + } + }); + this.dataChannels.clear(); + + // Close peer connection + if (this.pc.connectionState !== 'closed') { + this.pc.close(); + } + + this.emit('disconnect'); + } +} diff --git a/src/event-emitter.ts b/src/event-emitter.ts new file mode 100644 index 0000000..68f2f8a --- /dev/null +++ b/src/event-emitter.ts @@ -0,0 +1,86 @@ +/** + * Simple EventEmitter implementation for browser and Node.js compatibility + */ +export class EventEmitter { + private events: Map>; + + constructor() { + this.events = new Map(); + } + + /** + * Register an event listener + */ + on(event: string, listener: Function): this { + if (!this.events.has(event)) { + this.events.set(event, new Set()); + } + this.events.get(event)!.add(listener); + return this; + } + + /** + * Register a one-time event listener + */ + once(event: string, listener: Function): this { + const onceWrapper = (...args: any[]) => { + this.off(event, onceWrapper); + listener.apply(this, args); + }; + return this.on(event, onceWrapper); + } + + /** + * Remove an event listener + */ + off(event: string, listener: Function): this { + const listeners = this.events.get(event); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + this.events.delete(event); + } + } + return this; + } + + /** + * Emit an event + */ + emit(event: string, ...args: any[]): boolean { + const listeners = this.events.get(event); + if (!listeners || listeners.size === 0) { + return false; + } + + listeners.forEach(listener => { + try { + listener.apply(this, args); + } catch (err) { + console.error(`Error in ${event} event listener:`, err); + } + }); + + return true; + } + + /** + * Remove all listeners for an event (or all events if not specified) + */ + removeAllListeners(event?: string): this { + if (event) { + this.events.delete(event); + } else { + this.events.clear(); + } + return this; + } + + /** + * Get listener count for an event + */ + listenerCount(event: string): number { + const listeners = this.events.get(event); + return listeners ? listeners.size : 0; + } +} diff --git a/src/index.ts b/src/index.ts index b10619c..406a76c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,41 @@ /** - * TypeScript client for Rondevu peer signaling server - * - * @example - * ```typescript - * import { RondevuClient } from '@xtr-dev/rondevu-client'; - * - * const client = new RondevuClient({ - * baseUrl: 'https://rondevu.example.com' - * }); - * - * // Create an offer - * const { code } = await client.createOffer('my-room', { - * info: 'peer-123', - * offer: signalingData - * }); - * - * // Discover peers - * const { sessions } = await client.listSessions('my-room'); - * - * // Send answer - * await client.sendAnswer({ - * code: sessions[0].code, - * answer: answerData, - * side: 'answerer' - * }); - * ``` + * @xtr-dev/rondevu-client + * WebRTC peer signaling and discovery client */ +// Export main WebRTC client class +export { Rondevu } from './rondevu'; + +// Export connection class +export { RondevuConnection } from './connection'; + +// Export low-level signaling client (for advanced usage) export { RondevuClient } from './client'; -export * from './types'; + +// Export all types +export type { + // WebRTC types + RondevuOptions, + JoinOptions, + ConnectionRole, + RondevuConnectionParams, + RondevuConnectionEvents, + // Signaling types + Side, + Session, + TopicInfo, + Pagination, + ListTopicsResponse, + ListSessionsResponse, + CreateOfferRequest, + CreateOfferResponse, + AnswerRequest, + AnswerResponse, + PollRequest, + PollOffererResponse, + PollAnswererResponse, + PollResponse, + HealthResponse, + ErrorResponse, + RondevuClientOptions, +} from './types'; diff --git a/src/rondevu.ts b/src/rondevu.ts new file mode 100644 index 0000000..ce420c5 --- /dev/null +++ b/src/rondevu.ts @@ -0,0 +1,266 @@ +import { RondevuClient } from './client'; +import { RondevuConnection } from './connection'; +import { RondevuOptions, JoinOptions, RondevuConnectionParams } from './types'; + +/** + * Main Rondevu WebRTC client with automatic connection management + */ +export class Rondevu { + readonly peerId: string; + + private client: RondevuClient; + private rtcConfig?: RTCConfiguration; + private pollingInterval: number; + private connectionTimeout: number; + + /** + * Creates a new Rondevu client instance + * @param options - Client configuration options + */ + constructor(options: RondevuOptions) { + this.client = new RondevuClient({ + baseUrl: options.baseUrl, + origin: options.origin, + fetch: options.fetch, + }); + + // Auto-generate peer ID if not provided + this.peerId = options.peerId || this.generatePeerId(); + this.rtcConfig = options.rtcConfig; + this.pollingInterval = options.pollingInterval || 1000; + this.connectionTimeout = options.connectionTimeout || 30000; + } + + /** + * Generate a unique peer ID + */ + private generatePeerId(): string { + return `rdv_${Math.random().toString(36).substring(2, 14)}`; + } + + /** + * Update the peer ID (useful when user identity changes) + */ + updatePeerId(newPeerId: string): void { + (this as any).peerId = newPeerId; + } + + /** + * Create a new connection (offerer role) + * @param id - Connection identifier + * @param topic - Topic name for grouping connections + * @returns Promise that resolves to RondevuConnection + */ + async create(id: string, topic: string): Promise { + // Create peer connection + const pc = new RTCPeerConnection(this.rtcConfig); + + // Create initial data channel for negotiation (required for offer creation) + pc.createDataChannel('_negotiation'); + + // Generate offer + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + // Wait for ICE gathering to complete + await this.waitForIceGathering(pc); + + // Create session on server with custom code + await this.client.createOffer(topic, { + peerId: this.peerId, + offer: pc.localDescription!.sdp, + code: id, + }); + + // Create connection object + const connectionParams: RondevuConnectionParams = { + id, + topic, + role: 'offerer', + pc, + localPeerId: this.peerId, + remotePeerId: '', // Will be populated when answer is received + pollingInterval: this.pollingInterval, + connectionTimeout: this.connectionTimeout, + }; + + const connection = new RondevuConnection(connectionParams, this.client); + + // Start polling for answer + connection.startPolling(); + + return connection; + } + + /** + * Connect to an existing connection by ID (answerer role) + * @param id - Connection identifier + * @returns Promise that resolves to RondevuConnection + */ + async connect(id: string): Promise { + // Poll server to get session by ID + const sessionData = await this.findSessionById(id); + + if (!sessionData) { + throw new Error(`Connection ${id} not found or expired`); + } + + // Create peer connection + const pc = new RTCPeerConnection(this.rtcConfig); + + // Set remote offer + await pc.setRemoteDescription({ + type: 'offer', + sdp: sessionData.offer, + }); + + // Generate answer + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + // Wait for ICE gathering + await this.waitForIceGathering(pc); + + // Send answer to server + await this.client.sendAnswer({ + code: id, + answer: pc.localDescription!.sdp, + side: 'answerer', + }); + + // Create connection object + const connectionParams: RondevuConnectionParams = { + id, + topic: sessionData.topic || 'unknown', + role: 'answerer', + pc, + localPeerId: this.peerId, + remotePeerId: sessionData.peerId, + pollingInterval: this.pollingInterval, + connectionTimeout: this.connectionTimeout, + }; + + const connection = new RondevuConnection(connectionParams, this.client); + + // Start polling for ICE candidates + connection.startPolling(); + + return connection; + } + + /** + * Join a topic and discover available peers (answerer role) + * @param topic - Topic name + * @param options - Optional join options for filtering and selection + * @returns Promise that resolves to RondevuConnection + */ + async join(topic: string, options?: JoinOptions): Promise { + // List sessions in topic + const { sessions } = await this.client.listSessions(topic); + + // Filter out self (sessions with our peer ID) + let availableSessions = sessions.filter( + session => session.peerId !== this.peerId + ); + + // Apply custom filter if provided + if (options?.filter) { + availableSessions = availableSessions.filter(options.filter); + } + + if (availableSessions.length === 0) { + throw new Error(`No available peers in topic: ${topic}`); + } + + // Select session based on strategy + const selectedSession = this.selectSession( + availableSessions, + options?.select || 'first' + ); + + // Connect to selected session + return this.connect(selectedSession.code); + } + + /** + * Select a session based on strategy + */ + private selectSession( + sessions: Array<{ code: string; peerId: string; createdAt: number }>, + strategy: 'first' | 'newest' | 'oldest' | 'random' + ): { code: string; peerId: string; createdAt: number } { + switch (strategy) { + case 'first': + return sessions[0]; + case 'newest': + return sessions.reduce((newest, session) => + session.createdAt > newest.createdAt ? session : newest + ); + case 'oldest': + return sessions.reduce((oldest, session) => + session.createdAt < oldest.createdAt ? session : oldest + ); + case 'random': + return sessions[Math.floor(Math.random() * sessions.length)]; + default: + return sessions[0]; + } + } + + /** + * Wait for ICE gathering to complete + */ + private async waitForIceGathering(pc: RTCPeerConnection): Promise { + if (pc.iceGatheringState === 'complete') { + return; + } + + return new Promise((resolve) => { + const checkState = () => { + if (pc.iceGatheringState === 'complete') { + pc.removeEventListener('icegatheringstatechange', checkState); + resolve(); + } + }; + + pc.addEventListener('icegatheringstatechange', checkState); + + // Also set a timeout in case gathering takes too long + setTimeout(() => { + pc.removeEventListener('icegatheringstatechange', checkState); + resolve(); + }, 5000); + }); + } + + /** + * Find a session by connection ID + * This requires polling since we don't know which topic it's in + */ + private async findSessionById(id: string): Promise<{ + code: string; + peerId: string; + offer: string; + topic?: string; + } | null> { + try { + // Try to poll for the session directly + // The poll endpoint should return the session data + const response = await this.client.poll(id, 'answerer'); + const answererResponse = response as { offer: string; offerCandidates: string[] }; + + if (answererResponse.offer) { + return { + code: id, + peerId: '', // Will be populated from session data + offer: answererResponse.offer, + topic: undefined, + }; + } + + return null; + } catch (err) { + throw new Error(`Failed to find session ${id}: ${(err as Error).message}`); + } + } +} diff --git a/src/types.ts b/src/types.ts index 0e7fc40..eb2c529 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +// ============================================================================ +// Signaling Types +// ============================================================================ + /** * Session side - identifies which peer in a connection */ @@ -10,7 +14,7 @@ export interface Session { /** Unique session identifier (UUID) */ code: string; /** Peer identifier/metadata */ - info: string; + peerId: string; /** Signaling data for peer connection */ offer: string; /** Additional signaling data from offerer */ @@ -65,9 +69,11 @@ export interface ListSessionsResponse { */ export interface CreateOfferRequest { /** Peer identifier/metadata (max 1024 characters) */ - info: string; + peerId: string; /** Signaling data for peer connection */ offer: string; + /** Optional custom connection code (if not provided, server generates UUID) */ + code?: string; } /** @@ -160,3 +166,67 @@ export interface RondevuClientOptions { /** Optional fetch implementation (for Node.js environments) */ fetch?: typeof fetch; } + +// ============================================================================ +// WebRTC Types +// ============================================================================ + +/** + * Configuration options for Rondevu WebRTC client + */ +export interface RondevuOptions { + /** Base URL of the Rondevu server (e.g., 'https://example.com') */ + baseUrl: string; + /** Peer identifier (optional, auto-generated if not provided) */ + peerId?: string; + /** Origin header value for session isolation (defaults to baseUrl origin) */ + origin?: string; + /** Optional fetch implementation (for Node.js environments) */ + fetch?: typeof fetch; + /** WebRTC configuration (ICE servers, etc.) */ + rtcConfig?: RTCConfiguration; + /** Polling interval in milliseconds (default: 1000) */ + pollingInterval?: number; + /** Connection timeout in milliseconds (default: 30000) */ + connectionTimeout?: number; +} + +/** + * Options for joining a topic + */ +export interface JoinOptions { + /** Filter function to select specific sessions */ + filter?: (session: { code: string; peerId: string }) => boolean; + /** Selection strategy for choosing a session */ + select?: 'first' | 'newest' | 'oldest' | 'random'; +} + +/** + * Connection role - whether this peer is creating or answering + */ +export type ConnectionRole = 'offerer' | 'answerer'; + +/** + * Parameters for creating a RondevuConnection + */ +export interface RondevuConnectionParams { + id: string; + topic: string; + role: ConnectionRole; + pc: RTCPeerConnection; + localPeerId: string; + remotePeerId: string; + pollingInterval: number; + connectionTimeout: number; +} + +/** + * Event map for RondevuConnection events + */ +export interface RondevuConnectionEvents { + connect: () => void; + disconnect: () => void; + error: (error: Error) => void; + datachannel: (channel: RTCDataChannel) => void; + stream: (stream: MediaStream) => void; +} diff --git a/tsconfig.json b/tsconfig.json index 010df2c..2c17934 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", + "module": "ESNext", "lib": ["ES2020", "DOM"], "declaration": true, "outDir": "./dist", @@ -10,7 +10,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true }, "include": ["src/**/*"],