mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 10:53:24 +00:00
Implement RondevuAPI and RondevuSignaler classes
Added comprehensive API client and signaling implementation: **RondevuAPI** - Single class for all Rondevu endpoints: - Authentication: register() - Offers: createOffers(), getOffer(), answerOffer(), getAnswer(), searchOffers() - ICE Candidates: addIceCandidates(), getIceCandidates() - Services: publishService(), getService(), searchServices() - Usernames: checkUsername(), claimUsername() **RondevuSignaler** - ICE candidate exchange: - addIceCandidate() - Send local candidates to server - addListener() - Poll for remote candidates (1 second intervals) - Returns cleanup function (Binnable) to stop polling - Handles offer expiration gracefully **WebRTCRondevuConnection** - WebRTC connection wrapper: - Handles offer/answer creation - Manages ICE candidate exchange via Signaler - Type-safe event bus for state changes and messages - Queue and send message interfaces **Utilities**: - createBin() - Cleanup function collector - Binnable type - Cleanup function signature All classes use the shared RondevuAPI client for consistent error handling and authentication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AppEvents>();
|
|
||||||
|
|
||||||
// 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<ConnectionEvents>();
|
|
||||||
|
|
||||||
on<K extends keyof ConnectionEvents>(
|
|
||||||
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
|
|
||||||
375
src/api.ts
Normal file
375
src/api.ts
Normal file
@@ -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<string, any>;
|
||||||
|
signature: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
serviceId: string;
|
||||||
|
uuid: string;
|
||||||
|
offerId: string;
|
||||||
|
username: string;
|
||||||
|
serviceFqn: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
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<string, string> {
|
||||||
|
if (!this.credentials) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Authentication
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new peer and get credentials
|
||||||
|
*/
|
||||||
|
async register(): Promise<Credentials> {
|
||||||
|
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<Offer[]> {
|
||||||
|
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<Offer> {
|
||||||
|
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<void> {
|
||||||
|
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<Offer[]> {
|
||||||
|
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<void> {
|
||||||
|
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<IceCandidate[]> {
|
||||||
|
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<Service> {
|
||||||
|
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<Service & { offerId: string; sdp: string }> {
|
||||||
|
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<Service[]> {
|
||||||
|
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<Service[]> {
|
||||||
|
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<Service[]> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/bin.ts
Normal file
15
src/bin.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
export type Binnable = () => void | Promise<void>
|
||||||
|
|
||||||
|
export const createBin = () => {
|
||||||
|
const bin: Binnable[] = []
|
||||||
|
return Object.assign(
|
||||||
|
(...rubbish: Binnable[]) => bin.push(...rubbish),
|
||||||
|
{
|
||||||
|
clean: (): void => {
|
||||||
|
bin.forEach(binnable => binnable())
|
||||||
|
bin.length = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/connection.ts
Normal file
76
src/connection.ts
Normal file
@@ -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<ConnectionEvents> = new EventBus();
|
||||||
|
private signaler!: Signaler; // Will be set by setSignaler()
|
||||||
|
private readonly _ready: Promise<void>;
|
||||||
|
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<void> {
|
||||||
|
return this._ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(message: Message): Promise<boolean> {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,18 +87,4 @@ export class EventBus<TEvents extends Record<string, any>> {
|
|||||||
this.handlers.clear();
|
this.handlers.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of handlers for an event
|
|
||||||
*/
|
|
||||||
listenerCount<K extends keyof TEvents>(event: K): number {
|
|
||||||
return this.handlers.get(event)?.size ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all event names that have handlers
|
|
||||||
*/
|
|
||||||
eventNames(): Array<keyof TEvents> {
|
|
||||||
return Array.from(this.handlers.keys());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
23
src/index.ts
23
src/index.ts
@@ -5,12 +5,27 @@
|
|||||||
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
export { ConnectionManager } from './connection-manager.js';
|
||||||
export { EventBus } from './event-bus.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 types
|
||||||
export type {
|
export type {
|
||||||
ConnectionIdentity,
|
|
||||||
ConnectionState,
|
|
||||||
ConnectionInterface,
|
ConnectionInterface,
|
||||||
Connection,
|
QueueMessageOptions,
|
||||||
QueueMessageOptions
|
Message,
|
||||||
|
ConnectionEvents,
|
||||||
|
Signaler
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Credentials,
|
||||||
|
OfferRequest,
|
||||||
|
Offer,
|
||||||
|
ServiceRequest,
|
||||||
|
Service,
|
||||||
|
IceCandidate
|
||||||
|
} from './api.js';
|
||||||
|
|
||||||
|
export type { Binnable } from './bin.js';
|
||||||
|
|||||||
82
src/signaler.ts
Normal file
82
src/signaler.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/types.ts
36
src/types.ts
@@ -1,24 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Core connection types
|
* Core connection types
|
||||||
*/
|
*/
|
||||||
|
import {EventBus} from "./event-bus";
|
||||||
|
import {Binnable} from "./bin";
|
||||||
|
|
||||||
export interface ConnectionIdentity {
|
export type Message = string | ArrayBuffer;
|
||||||
id: string;
|
|
||||||
hostUsername: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionState {
|
|
||||||
state: 'connected' | 'disconnected' | 'connecting';
|
|
||||||
lastActive: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueMessageOptions {
|
export interface QueueMessageOptions {
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionInterface {
|
export interface ConnectionEvents {
|
||||||
queueMessage(message: string | ArrayBuffer, options?: QueueMessageOptions): void;
|
'state-change': ConnectionInterface['state']
|
||||||
sendMessage(message: string | ArrayBuffer): void;
|
'message': Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Connection = ConnectionIdentity & ConnectionState & ConnectionInterface;
|
export interface ConnectionInterface {
|
||||||
|
id: string;
|
||||||
|
host: string;
|
||||||
|
service: string;
|
||||||
|
state: 'connected' | 'disconnected' | 'connecting';
|
||||||
|
lastActive: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
events: EventBus<ConnectionEvents>;
|
||||||
|
|
||||||
|
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>;
|
||||||
|
sendMessage(message: Message): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Signaler {
|
||||||
|
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void;
|
||||||
|
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user