diff --git a/EVENTBUS_EXAMPLE.md b/EVENTBUS_EXAMPLE.md deleted file mode 100644 index 612e1ea..0000000 --- a/EVENTBUS_EXAMPLE.md +++ /dev/null @@ -1,120 +0,0 @@ -# EventBus Usage Examples - -## Type-Safe Event Bus - -The `EventBus` class provides fully type-safe event handling with TypeScript type inference. - -### Basic Usage - -```typescript -import { EventBus } from '@xtr-dev/rondevu-client'; - -// Define your event mapping -interface AppEvents { - 'user:connected': { userId: string; timestamp: number }; - 'user:disconnected': { userId: string }; - 'message:received': string; - 'connection:error': Error; -} - -// Create the event bus -const events = new EventBus(); - -// Subscribe to events - TypeScript knows the exact data type! -events.on('user:connected', (data) => { - // data is { userId: string; timestamp: number } - console.log(`User ${data.userId} connected at ${data.timestamp}`); -}); - -events.on('message:received', (data) => { - // data is string - console.log(data.toUpperCase()); -}); - -// Emit events - TypeScript validates the data type -events.emit('user:connected', { - userId: '123', - timestamp: Date.now() -}); - -events.emit('message:received', 'Hello World'); - -// Type errors caught at compile time: -// events.emit('user:connected', 'wrong type'); // ❌ Error! -// events.emit('message:received', { wrong: 'type' }); // ❌ Error! -``` - -### One-Time Listeners - -```typescript -// Subscribe once - handler auto-unsubscribes after first call -events.once('connection:error', (error) => { - console.error('Connection failed:', error.message); -}); -``` - -### Unsubscribing - -```typescript -const handler = (data: string) => { - console.log('Message:', data); -}; - -events.on('message:received', handler); - -// Later, unsubscribe -events.off('message:received', handler); -``` - -### Utility Methods - -```typescript -// Clear all handlers for a specific event -events.clear('message:received'); - -// Clear all handlers for all events -events.clear(); - -// Get listener count -const count = events.listenerCount('user:connected'); - -// Get all event names with handlers -const eventNames = events.eventNames(); -``` - -## Connection Events Example - -```typescript -interface ConnectionEvents { - 'connection:state': { state: 'connected' | 'disconnected' | 'connecting' }; - 'connection:message': { from: string; data: string | ArrayBuffer }; - 'connection:error': { code: string; message: string }; -} - -class ConnectionManager { - private events = new EventBus(); - - on( - event: K, - handler: (data: ConnectionEvents[K]) => void - ) { - this.events.on(event, handler); - } - - private handleStateChange(state: 'connected' | 'disconnected' | 'connecting') { - this.events.emit('connection:state', { state }); - } - - private handleMessage(from: string, data: string | ArrayBuffer) { - this.events.emit('connection:message', { from, data }); - } -} -``` - -## Benefits - -- ✅ **Full type safety** - TypeScript validates event names and data types -- ✅ **IntelliSense support** - Auto-completion for event names and data properties -- ✅ **Compile-time errors** - Catch type mismatches before runtime -- ✅ **Self-documenting** - Event interface serves as documentation -- ✅ **Refactoring-friendly** - Rename events or change types with confidence diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..061fcda --- /dev/null +++ b/src/api.ts @@ -0,0 +1,375 @@ +/** + * Rondevu API Client - Single class for all API endpoints + */ + +export interface Credentials { + peerId: string; + secret: string; +} + +export interface OfferRequest { + sdp: string; + topics?: string[]; + ttl?: number; + secret?: string; +} + +export interface Offer { + id: string; + peerId: string; + sdp: string; + topics: string[]; + ttl: number; + createdAt: number; + expiresAt: number; + answererPeerId?: string; +} + +export interface ServiceRequest { + username: string; + serviceFqn: string; + sdp: string; + ttl?: number; + isPublic?: boolean; + metadata?: Record; + signature: string; + message: string; +} + +export interface Service { + serviceId: string; + uuid: string; + offerId: string; + username: string; + serviceFqn: string; + isPublic: boolean; + metadata?: Record; + createdAt: number; + expiresAt: number; +} + +export interface IceCandidate { + candidate: RTCIceCandidateInit; + createdAt: number; +} + +/** + * RondevuAPI - Complete API client for Rondevu signaling server + */ +export class RondevuAPI { + constructor( + private baseUrl: string, + private credentials?: Credentials + ) {} + + /** + * Authentication header + */ + private getAuthHeader(): Record { + if (!this.credentials) { + return {}; + } + return { + 'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}` + }; + } + + // ============================================ + // Authentication + // ============================================ + + /** + * Register a new peer and get credentials + */ + async register(): Promise { + const response = await fetch(`${this.baseUrl}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Registration failed: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + // ============================================ + // Offers + // ============================================ + + /** + * Create one or more offers + */ + async createOffers(offers: OfferRequest[]): Promise { + const response = await fetch(`${this.baseUrl}/offers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ offers }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to create offers: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Get offer by ID + */ + async getOffer(offerId: string): Promise { + const response = await fetch(`${this.baseUrl}/offers/${offerId}`, { + headers: this.getAuthHeader() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get offer: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Answer an offer + */ + async answerOffer(offerId: string, sdp: string, secret?: string): Promise { + const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ sdp, secret }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to answer offer: ${error.error || response.statusText}`); + } + } + + /** + * Get answer for an offer (offerer polls this) + */ + async getAnswer(offerId: string): Promise<{ sdp: string } | null> { + const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, { + headers: this.getAuthHeader() + }); + + if (response.status === 404) { + return null; // No answer yet + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get answer: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Search offers by topic + */ + async searchOffers(topic: string): Promise { + const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, { + headers: this.getAuthHeader() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to search offers: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + // ============================================ + // ICE Candidates + // ============================================ + + /** + * Add ICE candidates to an offer + */ + async addIceCandidates(offerId: string, candidates: RTCIceCandidateInit[]): Promise { + const response = await fetch(`${this.baseUrl}/offers/${offerId}/ice-candidates`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ candidates }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`); + } + } + + /** + * Get ICE candidates for an offer (with polling support) + */ + async getIceCandidates(offerId: string, since: number = 0): Promise { + const response = await fetch( + `${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`, + { headers: this.getAuthHeader() } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + // ============================================ + // Services + // ============================================ + + /** + * Publish a service + */ + async publishService(service: ServiceRequest): Promise { + const response = await fetch(`${this.baseUrl}/services`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify(service) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to publish service: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Get service by UUID + */ + async getService(uuid: string): Promise { + const response = await fetch(`${this.baseUrl}/services/${uuid}`, { + headers: this.getAuthHeader() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to get service: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Search services by username + */ + async searchServicesByUsername(username: string): Promise { + const response = await fetch( + `${this.baseUrl}/services?username=${encodeURIComponent(username)}`, + { headers: this.getAuthHeader() } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to search services: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Search services by FQN + */ + async searchServicesByFqn(serviceFqn: string): Promise { + const response = await fetch( + `${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`, + { headers: this.getAuthHeader() } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to search services: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Search services by username AND FQN + */ + async searchServices(username: string, serviceFqn: string): Promise { + const response = await fetch( + `${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`, + { headers: this.getAuthHeader() } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to search services: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + // ============================================ + // Usernames + // ============================================ + + /** + * Check if username is available + */ + async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> { + const response = await fetch( + `${this.baseUrl}/usernames/${encodeURIComponent(username)}/check` + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to check username: ${error.error || response.statusText}`); + } + + return await response.json(); + } + + /** + * Claim a username (requires Ed25519 signature) + */ + async claimUsername( + username: string, + publicKey: string, + signature: string, + message: string + ): Promise<{ success: boolean; username: string }> { + const response = await fetch(`${this.baseUrl}/usernames/${encodeURIComponent(username)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ + publicKey, + signature, + message + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(`Failed to claim username: ${error.error || response.statusText}`); + } + + return await response.json(); + } +} diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..d29ef24 --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,15 @@ + +export type Binnable = () => void | Promise + +export const createBin = () => { + const bin: Binnable[] = [] + return Object.assign( + (...rubbish: Binnable[]) => bin.push(...rubbish), + { + clean: (): void => { + bin.forEach(binnable => binnable()) + bin.length = 0 + } + } + ) +} \ No newline at end of file diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..666733e --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,76 @@ +import {ConnectionEvents, ConnectionInterface, Message, QueueMessageOptions, Signaler} from "./types"; +import {EventBus} from "./event-bus"; +import {createBin} from "./bin"; + +export class WebRTCRondevuConnection implements ConnectionInterface { + private readonly connection: RTCPeerConnection; + private readonly side: 'offer' | 'answer'; + public readonly expiresAt: number = 0; + public readonly lastActive: number = 0; + public readonly events: EventBus = new EventBus(); + private signaler!: Signaler; // Will be set by setSignaler() + private readonly _ready: Promise; + private _state: ConnectionInterface['state'] = 'disconnected'; + private iceBin = createBin() + + constructor( + public readonly id: string, + public readonly host: string, + public readonly service: string, + offer?: RTCSessionDescriptionInit) { + this.connection = new RTCPeerConnection(); + this.side = offer ? 'answer' : 'offer'; + const ready = offer + ? this.connection.setRemoteDescription(offer) + .then(() => this.connection.createAnswer()) + .then(answer => this.connection.setLocalDescription(answer)) + : this.connection.createOffer() + .then(offer => this.connection.setLocalDescription(offer)); + this._ready = ready.then(() => this.setState('connecting')) + .then(() => this.startIceListeners()) + } + + private setState(state: ConnectionInterface['state']) { + this._state = state; + this.events.emit('state-change', state); + } + + private startIceListeners() { + const listener = ({candidate}: {candidate: RTCIceCandidate | null}) => { + if (candidate) this.signaler.addIceCandidate(candidate) + } + this.connection.addEventListener('icecandidate', listener) + this.iceBin( + this.signaler.addListener((candidate: RTCIceCandidate) => this.connection.addIceCandidate(candidate)), + () => this.connection.removeEventListener('icecandidate', listener) + ) + } + + private stopIceListeners() { + this.iceBin.clean() + } + + /** + * Set the signaler for ICE candidate exchange + * Must be called before connection is ready + */ + setSignaler(signaler: Signaler): void { + this.signaler = signaler; + } + + get state() { + return this._state; + } + + get ready(): Promise { + return this._ready; + } + + queueMessage(message: Message, options: QueueMessageOptions = {}): Promise { + return Promise.resolve(undefined); + } + + sendMessage(message: Message): Promise { + return Promise.resolve(false); + } +} \ No newline at end of file diff --git a/src/event-bus.ts b/src/event-bus.ts index 8d3d2b9..893756a 100644 --- a/src/event-bus.ts +++ b/src/event-bus.ts @@ -87,18 +87,4 @@ export class EventBus> { this.handlers.clear(); } } - - /** - * Get count of handlers for an event - */ - listenerCount(event: K): number { - return this.handlers.get(event)?.size ?? 0; - } - - /** - * Get all event names that have handlers - */ - eventNames(): Array { - return Array.from(this.handlers.keys()); - } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c209972..3431a8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,27 @@ export { ConnectionManager } from './connection-manager.js'; export { EventBus } from './event-bus.js'; +export { RondevuAPI } from './api.js'; +export { RondevuSignaler } from './signaler.js'; +export { WebRTCRondevuConnection } from './connection.js'; +export { createBin } from './bin.js'; // Export types export type { - ConnectionIdentity, - ConnectionState, ConnectionInterface, - Connection, - QueueMessageOptions + QueueMessageOptions, + Message, + ConnectionEvents, + Signaler } from './types.js'; + +export type { + Credentials, + OfferRequest, + Offer, + ServiceRequest, + Service, + IceCandidate +} from './api.js'; + +export type { Binnable } from './bin.js'; diff --git a/src/signaler.ts b/src/signaler.ts new file mode 100644 index 0000000..997ab62 --- /dev/null +++ b/src/signaler.ts @@ -0,0 +1,82 @@ +import {Signaler} from "./types"; +import {Binnable} from "./bin"; +import {RondevuAPI} from "./api"; + +/** + * RondevuSignaler - Handles ICE candidate exchange via Rondevu API + * Uses polling to retrieve remote candidates + */ +export class RondevuSignaler implements Signaler { + constructor( + private api: RondevuAPI, + private offerId: string + ) {} + + /** + * Send local ICE candidate to signaling server + */ + async addIceCandidate(candidate: RTCIceCandidate): Promise { + const candidateData = candidate.toJSON(); + + // Skip empty candidates + if (!candidateData.candidate || candidateData.candidate === '') { + return; + } + + await this.api.addIceCandidates(this.offerId, [candidateData]); + } + + /** + * Poll for remote ICE candidates and call callback for each one + * Returns cleanup function to stop polling + */ + addListener(callback: (candidate: RTCIceCandidate) => void): Binnable { + let lastTimestamp = 0; + let polling = true; + + const poll = async () => { + while (polling) { + try { + const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp); + + // Process each candidate + for (const item of candidates) { + if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') { + try { + const rtcCandidate = new RTCIceCandidate(item.candidate); + callback(rtcCandidate); + lastTimestamp = item.createdAt; + } catch (err) { + console.warn('Failed to process ICE candidate:', err); + lastTimestamp = item.createdAt; + } + } else { + lastTimestamp = item.createdAt; + } + } + } catch (err) { + // If offer not found or expired, stop polling + if (err instanceof Error && (err.message.includes('404') || err.message.includes('410'))) { + console.warn('Offer not found or expired, stopping ICE polling'); + polling = false; + break; + } + console.error('Error polling for ICE candidates:', err); + } + + // Poll every second + if (polling) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + }; + + // Start polling in background + poll(); + + // Return cleanup function + return () => { + polling = false; + }; + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index d8e06c7..66d2511 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,24 +1,34 @@ /** * Core connection types */ +import {EventBus} from "./event-bus"; +import {Binnable} from "./bin"; -export interface ConnectionIdentity { - id: string; - hostUsername: string; -} - -export interface ConnectionState { - state: 'connected' | 'disconnected' | 'connecting'; - lastActive: number; -} +export type Message = string | ArrayBuffer; export interface QueueMessageOptions { expiresAt?: number; } -export interface ConnectionInterface { - queueMessage(message: string | ArrayBuffer, options?: QueueMessageOptions): void; - sendMessage(message: string | ArrayBuffer): void; +export interface ConnectionEvents { + 'state-change': ConnectionInterface['state'] + 'message': Message; } -export type Connection = ConnectionIdentity & ConnectionState & ConnectionInterface; \ No newline at end of file +export interface ConnectionInterface { + id: string; + host: string; + service: string; + state: 'connected' | 'disconnected' | 'connecting'; + lastActive: number; + expiresAt?: number; + events: EventBus; + + queueMessage(message: Message, options?: QueueMessageOptions): Promise; + sendMessage(message: Message): Promise; +} + +export interface Signaler { + addIceCandidate(candidate: RTCIceCandidate): Promise | void; + addListener(callback: (candidate: RTCIceCandidate) => void): Binnable; +} \ No newline at end of file