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:
2025-12-07 17:40:17 +01:00
parent 5e673ac993
commit 58cd610694
8 changed files with 590 additions and 151 deletions

375
src/api.ts Normal file
View 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
View 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
View 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);
}
}

View File

@@ -87,18 +87,4 @@ export class EventBus<TEvents extends Record<string, any>> {
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());
}
}

View File

@@ -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';

82
src/signaler.ts Normal file
View 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;
};
}
}

View File

@@ -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;
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;
}