Add ServiceHost, ServiceClient, and RondevuService for high-level service management

- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures
- Add ServiceHost: Manages offer pool for hosting services with auto-replacement
- Add ServiceClient: Connects to hosted services with automatic reconnection
- Add NoOpSignaler: Placeholder signaler for connection setup
- Integrate Ed25519 signature functionality from @noble/ed25519
- Add ESLint and Prettier configuration with 4-space indentation
- Add demo with local signaling test
- Version bump to 0.10.0

🤖 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 19:37:43 +01:00
parent 945d5a8792
commit 54355323d9
21 changed files with 5066 additions and 307 deletions

View File

@@ -2,55 +2,83 @@
* Rondevu API Client - Single class for all API endpoints
*/
import * as ed25519 from '@noble/ed25519'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
}
export interface Credentials {
peerId: string;
secret: string;
peerId: string
secret: string
}
export interface Keypair {
publicKey: string
privateKey: string
}
export interface OfferRequest {
sdp: string;
topics?: string[];
ttl?: number;
secret?: string;
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;
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;
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;
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;
candidate: RTCIceCandidateInit
createdAt: number
}
/**
* Helper: Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
}
/**
* Helper: Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
}
/**
@@ -67,11 +95,56 @@ export class RondevuAPI {
*/
private getAuthHeader(): Record<string, string> {
if (!this.credentials) {
return {};
return {}
}
return {
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
};
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
}
}
// ============================================
// Ed25519 Cryptography Helpers
// ============================================
/**
* Generate an Ed25519 keypair for username claiming and service publishing
*/
static async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
}
/**
* Sign a message with an Ed25519 private key
*/
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return bytesToBase64(signature)
}
/**
* Verify a signature
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64)
const signature = base64ToBytes(signatureBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
}
// ============================================
@@ -84,15 +157,15 @@ export class RondevuAPI {
async register(): Promise<Credentials> {
const response = await fetch(`${this.baseUrl}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Registration failed: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -107,17 +180,17 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ offers })
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -125,15 +198,15 @@ export class RondevuAPI {
*/
async getOffer(offerId: string): Promise<Offer> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
headers: this.getAuthHeader()
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -144,14 +217,14 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ sdp, secret })
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
}
}
@@ -160,19 +233,19 @@ export class RondevuAPI {
*/
async getAnswer(offerId: string): Promise<{ sdp: string } | null> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
headers: this.getAuthHeader()
});
headers: this.getAuthHeader(),
})
if (response.status === 404) {
return null; // No answer yet
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -180,15 +253,15 @@ export class RondevuAPI {
*/
async searchOffers(topic: string): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
headers: this.getAuthHeader()
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -203,14 +276,14 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ candidates })
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
}
}
@@ -221,14 +294,14 @@ export class RondevuAPI {
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}`);
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();
return await response.json()
}
// ============================================
@@ -243,17 +316,17 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify(service)
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -261,15 +334,15 @@ export class RondevuAPI {
*/
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
headers: this.getAuthHeader()
});
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -279,14 +352,14 @@ export class RondevuAPI {
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -296,14 +369,14 @@ export class RondevuAPI {
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -313,14 +386,14 @@ export class RondevuAPI {
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -333,14 +406,14 @@ export class RondevuAPI {
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}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -356,20 +429,20 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({
publicKey,
signature,
message
})
});
message,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to claim username: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
}

View File

@@ -1,15 +1,42 @@
/**
* Binnable - A cleanup function that can be synchronous or asynchronous
*
* Used to unsubscribe from events, close connections, or perform other cleanup operations.
*/
export type Binnable = () => void | Promise<void>
/**
* Create a cleanup function collector (garbage bin)
*
* Collects cleanup functions and provides a single `clean()` method to execute all of them.
* Useful for managing multiple cleanup operations in a single place.
*
* @returns A function that accepts cleanup functions and has a `clean()` method
*
* @example
* ```typescript
* const bin = createBin();
*
* // Add cleanup functions
* bin(
* () => console.log('Cleanup 1'),
* () => connection.close(),
* () => clearInterval(timer)
* );
*
* // Later, clean everything
* bin.clean(); // Executes all cleanup functions
* ```
*/
export const createBin = () => {
const bin: Binnable[] = []
return Object.assign(
(...rubbish: Binnable[]) => bin.push(...rubbish),
{
clean: (): void => {
bin.forEach(binnable => binnable())
bin.length = 0
}
}
)
}
return Object.assign((...rubbish: Binnable[]) => bin.push(...rubbish), {
/**
* Execute all cleanup functions and clear the bin
*/
clean: (): void => {
bin.forEach(binnable => binnable())
bin.length = 0
},
})
}

View File

@@ -1,9 +0,0 @@
/**
* ConnectionManager - Manages WebRTC peer connections
*/
export class ConnectionManager {
constructor() {
// TODO: Initialize connection manager
}
}

View File

@@ -1,76 +1,229 @@
import {ConnectionEvents, ConnectionInterface, Message, QueueMessageOptions, Signaler} from "./types";
import {EventBus} from "./event-bus";
import {createBin} from "./bin";
import {
ConnectionEvents,
ConnectionInterface,
ConnectionStates,
isConnectionState,
Message,
QueueMessageOptions,
Signaler,
} from './types.js'
import { EventBus } from './event-bus.js'
import { createBin } from './bin.js'
import { WebRTCContext } from './webrtc-context'
export type WebRTCRondevuConnectionOptions = {
id: string
service: string
offer: RTCSessionDescriptionInit | null
context: WebRTCContext
}
/**
* WebRTCRondevuConnection - WebRTC peer connection wrapper with Rondevu signaling
*
* Manages a WebRTC peer connection lifecycle including:
* - Automatic offer/answer creation based on role
* - ICE candidate exchange via Rondevu signaling server
* - Connection state management with type-safe events
* - Data channel creation and message handling
*
* The connection automatically determines its role (offerer or answerer) based on whether
* an offer is provided in the constructor. The offerer creates the data channel, while
* the answerer receives it via the 'datachannel' event.
*
* @example
* ```typescript
* // Offerer side (creates offer)
* const connection = new WebRTCRondevuConnection(
* 'conn-123',
* 'peer-username',
* 'chat.service@1.0.0'
* );
*
* await connection.ready; // Wait for local offer
* const sdp = connection.connection.localDescription!.sdp!;
* // Send sdp to signaling server...
*
* // Answerer side (receives offer)
* const connection = new WebRTCRondevuConnection(
* 'conn-123',
* 'peer-username',
* 'chat.service@1.0.0',
* { type: 'offer', sdp: remoteOfferSdp }
* );
*
* await connection.ready; // Wait for local answer
* const answerSdp = connection.connection.localDescription!.sdp!;
* // Send answer to signaling server...
*
* // Both sides: Set up signaler and listen for state changes
* connection.setSignaler(signaler);
* connection.events.on('state-change', (state) => {
* console.log('Connection state:', state);
* });
* ```
*/
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 readonly side: 'offer' | 'answer'
public readonly expiresAt: number = 0
public readonly lastActive: number = 0
public readonly events: EventBus<ConnectionEvents> = new EventBus()
public readonly ready: Promise<void>
private iceBin = createBin()
private ctx: WebRTCContext
public id: string
public service: string
private _conn: RTCPeerConnection | null = null
private _state: ConnectionInterface['state'] = 'disconnected'
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())
}
constructor({ context: ctx, offer, id, service }: WebRTCRondevuConnectionOptions) {
this.ctx = ctx
this.id = id
this.service = service
this._conn = ctx.createPeerConnection()
this.side = offer ? 'answer' : 'offer'
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)
// setup data channel
if (offer) {
this._conn.addEventListener('datachannel', e => {
const channel = e.channel
channel.addEventListener('message', e => {
console.log('Message from peer:', e)
})
channel.addEventListener('open', () => {
channel.send('I am ' + this.side)
})
})
} else {
const channel = this._conn.createDataChannel('vu.ronde.protocol')
channel.addEventListener('message', e => {
console.log('Message from peer:', e)
})
channel.addEventListener('open', () => {
channel.send('I am ' + this.side)
})
}
this.connection.addEventListener('icecandidate', listener)
// setup description exchange
this.ready = offer
? this._conn
.setRemoteDescription(offer)
.then(() => this._conn?.createAnswer())
.then(async answer => {
if (!answer || !this._conn) throw new Error('Connection disappeared')
await this._conn.setLocalDescription(answer)
return await ctx.signaler.setAnswer(answer)
})
: this._conn.createOffer().then(async offer => {
if (!this._conn) throw new Error('Connection disappeared')
await this._conn.setLocalDescription(offer)
return await ctx.signaler.setOffer(offer)
})
// propagate connection state changes
this._conn.addEventListener('connectionstatechange', () => {
console.log(this.side, 'connection state changed: ', this._conn!.connectionState)
const state = isConnectionState(this._conn!.connectionState)
? this._conn!.connectionState
: 'disconnected'
this.setState(state)
})
this._conn.addEventListener('iceconnectionstatechange', () => {
console.log(this.side, 'ice connection state changed: ', this._conn!.iceConnectionState)
})
// start ICE candidate exchange when gathering begins
this._conn.addEventListener('icegatheringstatechange', () => {
if (this._conn!.iceGatheringState === 'gathering') {
this.startIce()
} else if (this._conn!.iceGatheringState === 'complete') {
this.stopIce()
}
})
}
/**
* Getter method for retrieving the current connection.
*
* @return {RTCPeerConnection|null} The current connection instance.
*/
public get connection(): RTCPeerConnection | null {
return this._conn
}
/**
* Update connection state and emit state-change event
*/
private setState(state: ConnectionInterface['state']) {
this._state = state
this.events.emit('state-change', state)
}
/**
* Start ICE candidate exchange when gathering begins
*/
private startIce() {
const listener = ({ candidate }: { candidate: RTCIceCandidate | null }) => {
if (candidate) this.ctx.signaler.addIceCandidate(candidate)
}
if (!this._conn) throw new Error('Connection disappeared')
this._conn.addEventListener('icecandidate', listener)
this.iceBin(
this.signaler.addListener((candidate: RTCIceCandidate) => this.connection.addIceCandidate(candidate)),
() => this.connection.removeEventListener('icecandidate', listener)
this.ctx.signaler.addListener((candidate: RTCIceCandidate) =>
this._conn?.addIceCandidate(candidate)
),
() => this._conn?.removeEventListener('icecandidate', listener)
)
}
private stopIceListeners() {
/**
* Stop ICE candidate exchange when gathering completes
*/
private stopIce() {
this.iceBin.clean()
}
/**
* Set the signaler for ICE candidate exchange
* Must be called before connection is ready
* Disconnects the current connection and cleans up resources.
* Closes the active connection if it exists, resets the connection instance to null,
* stops the ICE process, and updates the state to 'disconnected'.
*
* @return {void} No return value.
*/
setSignaler(signaler: Signaler): void {
this.signaler = signaler;
disconnect(): void {
this._conn?.close()
this._conn = null
this.stopIce()
this.setState('disconnected')
}
/**
* Current connection state
*/
get state() {
return this._state;
}
get ready(): Promise<void> {
return this._ready;
return this._state
}
/**
* Queue a message for sending when connection is established
*
* @param message - Message to queue (string or ArrayBuffer)
* @param options - Queue options (e.g., expiration time)
*/
queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
return Promise.resolve(undefined);
// TODO: Implement message queuing
return Promise.resolve(undefined)
}
/**
* Send a message immediately
*
* @param message - Message to send (string or ArrayBuffer)
* @returns Promise resolving to true if sent successfully
*/
sendMessage(message: Message): Promise<boolean> {
return Promise.resolve(false);
// TODO: Implement message sending via data channel
return Promise.resolve(false)
}
}
}

View File

@@ -2,7 +2,7 @@
* Type-safe EventBus with event name to payload type mapping
*/
type EventHandler<T = any> = (data: T) => void;
type EventHandler<T = any> = (data: T) => void
/**
* EventBus - Type-safe event emitter with inferred event data types
@@ -27,64 +27,68 @@ type EventHandler<T = any> = (data: T) => void;
* });
*/
export class EventBus<TEvents extends Record<string, any>> {
private handlers: Map<keyof TEvents, Set<EventHandler>>;
private handlers: Map<keyof TEvents, Set<EventHandler>>
constructor() {
this.handlers = new Map();
}
/**
* Subscribe to an event
*/
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
constructor() {
this.handlers = new Map()
}
this.handlers.get(event)!.add(handler);
}
/**
* Subscribe to an event once (auto-unsubscribe after first call)
*/
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const wrappedHandler = (data: TEvents[K]) => {
handler(data);
this.off(event, wrappedHandler);
};
this.on(event, wrappedHandler);
}
/**
* Subscribe to an event
* Returns a cleanup function to unsubscribe
*/
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler)
/**
* Unsubscribe from an event
*/
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler);
if (eventHandlers.size === 0) {
this.handlers.delete(event);
}
// Return cleanup function
return () => this.off(event, handler)
}
}
/**
* Emit an event with data
*/
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.forEach(handler => handler(data));
/**
* Subscribe to an event once (auto-unsubscribe after first call)
*/
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const wrappedHandler = (data: TEvents[K]) => {
handler(data)
this.off(event, wrappedHandler)
}
this.on(event, wrappedHandler)
}
}
/**
* Remove all handlers for a specific event, or all handlers if no event specified
*/
clear<K extends keyof TEvents>(event?: K): void {
if (event !== undefined) {
this.handlers.delete(event);
} else {
this.handlers.clear();
/**
* Unsubscribe from an event
*/
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const eventHandlers = this.handlers.get(event)
if (eventHandlers) {
eventHandlers.delete(handler)
if (eventHandlers.size === 0) {
this.handlers.delete(event)
}
}
}
}
}
/**
* Emit an event with data
*/
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
const eventHandlers = this.handlers.get(event)
if (eventHandlers) {
eventHandlers.forEach(handler => handler(data))
}
}
/**
* Remove all handlers for a specific event, or all handlers if no event specified
*/
clear<K extends keyof TEvents>(event?: K): void {
if (event !== undefined) {
this.handlers.delete(event)
} else {
this.handlers.clear()
}
}
}

View File

@@ -3,29 +3,38 @@
* WebRTC peer signaling client
*/
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 { EventBus } from './event-bus.js'
export { RondevuAPI } from './api.js'
export { RondevuService } from './rondevu-service.js'
export { RondevuSignaler } from './signaler.js'
export { ServiceHost } from './service-host.js'
export { ServiceClient } from './service-client.js'
export { WebRTCRondevuConnection } from './connection.js'
export { createBin } from './bin.js'
// Export types
export type {
ConnectionInterface,
QueueMessageOptions,
Message,
ConnectionEvents,
Signaler
} from './types.js';
ConnectionInterface,
QueueMessageOptions,
Message,
ConnectionEvents,
Signaler,
} from './types.js'
export type {
Credentials,
OfferRequest,
Offer,
ServiceRequest,
Service,
IceCandidate
} from './api.js';
Credentials,
Keypair,
OfferRequest,
Offer,
ServiceRequest,
Service,
IceCandidate,
} from './api.js'
export type { Binnable } from './bin.js';
export type { Binnable } from './bin.js'
export type { RondevuServiceOptions, PublishServiceOptions } from './rondevu-service.js'
export type { ServiceHostOptions, ServiceHostEvents } from './service-host.js'
export type { ServiceClientOptions, ServiceClientEvents } from './service-client.js'

35
src/noop-signaler.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Signaler } from './types.js'
import { Binnable } from './bin.js'
/**
* NoOpSignaler - A signaler that does nothing
* Used as a placeholder during connection setup before the real signaler is available
*/
export class NoOpSignaler implements Signaler {
addIceCandidate(_candidate: RTCIceCandidate): void {
// No-op
}
addListener(_callback: (candidate: RTCIceCandidate) => void): Binnable {
// Return no-op cleanup function
return () => {}
}
addOfferListener(_callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
// Return no-op cleanup function
return () => {}
}
addAnswerListener(_callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
// Return no-op cleanup function
return () => {}
}
async setOffer(_offer: RTCSessionDescriptionInit): Promise<void> {
// No-op
}
async setAnswer(_answer: RTCSessionDescriptionInit): Promise<void> {
// No-op
}
}

168
src/rondevu-service.ts Normal file
View File

@@ -0,0 +1,168 @@
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest } from './api.js'
export interface RondevuServiceOptions {
apiUrl: string
username: string
keypair?: Keypair
credentials?: Credentials
}
export interface PublishServiceOptions {
serviceFqn: string
sdp: string
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
}
/**
* RondevuService - High-level service management with automatic signature handling
*
* Provides a simplified API for:
* - Username claiming with Ed25519 signatures
* - Service publishing with automatic signature generation
* - Keypair management
*
* @example
* ```typescript
* // Initialize service (generates keypair automatically)
* const service = new RondevuService({
* apiUrl: 'https://signal.example.com',
* username: 'myusername',
* })
*
* await service.initialize()
*
* // Claim username (one time)
* await service.claimUsername()
*
* // Publish a service
* const publishedService = await service.publishService({
* serviceFqn: 'chat.app@1.0.0',
* sdp: offerSdp,
* ttl: 300000,
* isPublic: true,
* })
* ```
*/
export class RondevuService {
private readonly api: RondevuAPI
private readonly username: string
private keypair: Keypair | null = null
private usernameClaimed = false
constructor(options: RondevuServiceOptions) {
this.username = options.username
this.keypair = options.keypair || null
this.api = new RondevuAPI(options.apiUrl, options.credentials)
}
/**
* Initialize the service - generates keypair if not provided
* Call this before using other methods
*/
async initialize(): Promise<void> {
if (!this.keypair) {
this.keypair = await RondevuAPI.generateKeypair()
}
// Register with API if no credentials provided
if (!this.api['credentials']) {
const credentials = await this.api.register()
;(this.api as any).credentials = credentials
}
}
/**
* Claim the username with Ed25519 signature
* Should be called once before publishing services
*/
async claimUsername(): Promise<void> {
if (!this.keypair) {
throw new Error('Service not initialized. Call initialize() first.')
}
// Check if username is already claimed
const check = await this.api.checkUsername(this.username)
if (!check.available) {
// Verify it's claimed by us
if (check.owner === this.keypair.publicKey) {
this.usernameClaimed = true
return
}
throw new Error(`Username "${this.username}" is already claimed by another user`)
}
// Generate signature for username claim
const message = `claim-username-${this.username}-${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Claim the username
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
this.usernameClaimed = true
}
/**
* Publish a service with automatic signature generation
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('Service not initialized. Call initialize() first.')
}
if (!this.usernameClaimed) {
throw new Error(
'Username not claimed. Call claimUsername() first or the server will reject the service.'
)
}
const { serviceFqn, sdp, ttl, isPublic, metadata } = options
// Generate signature for service publication
const message = `publish-${this.username}-${serviceFqn}-${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Create service request
const serviceRequest: ServiceRequest = {
username: this.username,
serviceFqn,
sdp,
signature,
message,
ttl,
isPublic,
metadata,
}
// Publish to server
return await this.api.publishService(serviceRequest)
}
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
return this.keypair
}
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
}
/**
* Check if username has been claimed
*/
isUsernameClaimed(): boolean {
return this.usernameClaimed
}
/**
* Access to underlying API for advanced operations
*/
getAPI(): RondevuAPI {
return this.api
}
}

244
src/service-client.ts Normal file
View File

@@ -0,0 +1,244 @@
import { WebRTCRondevuConnection } from './connection.js'
import { WebRTCContext } from './webrtc-context.js'
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './signaler.js'
import { EventBus } from './event-bus.js'
import { createBin } from './bin.js'
import { ConnectionInterface } from './types.js'
export interface ServiceClientOptions {
username: string
serviceFqn: string
rondevuService: RondevuService
autoReconnect?: boolean
reconnectDelay?: number
maxReconnectAttempts?: number
}
export interface ServiceClientEvents {
connected: ConnectionInterface
disconnected: { reason: string }
reconnecting: { attempt: number; maxAttempts: number }
error: Error
}
/**
* ServiceClient - Connects to a hosted service
*
* Searches for available service offers and establishes a WebRTC connection.
* Optionally supports automatic reconnection on failure.
*
* @example
* ```typescript
* const rondevuService = new RondevuService({
* apiUrl: 'https://signal.example.com',
* username: 'client-user',
* })
*
* await rondevuService.initialize()
*
* const client = new ServiceClient({
* username: 'host-user',
* serviceFqn: 'chat.app@1.0.0',
* rondevuService,
* autoReconnect: true,
* })
*
* await client.connect()
*
* client.events.on('connected', (conn) => {
* console.log('Connected to service')
* conn.sendMessage('Hello!')
* })
* ```
*/
export class ServiceClient {
private readonly username: string
private readonly serviceFqn: string
private readonly rondevuService: RondevuService
private readonly autoReconnect: boolean
private readonly reconnectDelay: number
private readonly maxReconnectAttempts: number
private connection: WebRTCRondevuConnection | null = null
private reconnectAttempts = 0
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private readonly bin = createBin()
private isConnecting = false
public readonly events = new EventBus<ServiceClientEvents>()
constructor(options: ServiceClientOptions) {
this.username = options.username
this.serviceFqn = options.serviceFqn
this.rondevuService = options.rondevuService
this.autoReconnect = options.autoReconnect !== false
this.reconnectDelay = options.reconnectDelay || 2000
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
}
/**
* Connect to the service
*/
async connect(): Promise<WebRTCRondevuConnection> {
if (this.isConnecting) {
throw new Error('Already connecting')
}
if (this.connection && this.connection.state === 'connected') {
return this.connection
}
this.isConnecting = true
try {
// Search for available services
const services = await this.rondevuService
.getAPI()
.searchServices(this.username, this.serviceFqn)
if (services.length === 0) {
throw new Error(`No services found for ${this.username}/${this.serviceFqn}`)
}
// Get the first available service
const service = services[0]
// Get service details including SDP
const serviceDetails = await this.rondevuService.getAPI().getService(service.uuid)
// Create WebRTC context with signaler for this offer
const signaler = new RondevuSignaler(
this.rondevuService.getAPI(),
serviceDetails.offerId
)
const context = new WebRTCContext(signaler)
// Create connection (answerer role)
const conn = new WebRTCRondevuConnection({
id: `client-${this.serviceFqn}-${Date.now()}`,
service: this.serviceFqn,
offer: {
type: 'offer',
sdp: serviceDetails.sdp,
},
context,
})
// Wait for answer to be created
await conn.ready
// Get answer SDP
if (!conn.connection?.localDescription?.sdp) {
throw new Error('Failed to create answer SDP')
}
const answerSdp = conn.connection.localDescription.sdp
// Send answer to server
await this.rondevuService.getAPI().answerOffer(serviceDetails.offerId, answerSdp)
// Track connection
this.connection = conn
this.reconnectAttempts = 0
// Listen for state changes
const cleanup = conn.events.on('state-change', state => {
this.handleConnectionStateChange(state)
})
this.bin(cleanup)
this.isConnecting = false
// Emit connected event when actually connected
if (conn.state === 'connected') {
this.events.emit('connected', conn)
}
return conn
} catch (error) {
this.isConnecting = false
this.events.emit('error', error as Error)
throw error
}
}
/**
* Disconnect from the service
*/
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.connection) {
this.connection.disconnect()
this.connection = null
}
this.bin.clean()
this.reconnectAttempts = 0
}
/**
* Get the current connection
*/
getConnection(): WebRTCRondevuConnection | null {
return this.connection
}
/**
* Check if currently connected
*/
isConnected(): boolean {
return this.connection?.state === 'connected'
}
/**
* Handle connection state changes
*/
private handleConnectionStateChange(state: ConnectionInterface['state']): void {
if (state === 'connected') {
this.events.emit('connected', this.connection!)
this.reconnectAttempts = 0
} else if (state === 'disconnected') {
this.events.emit('disconnected', { reason: 'Connection closed' })
// Attempt reconnection if enabled
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
}
/**
* Schedule a reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimeout) {
return
}
this.reconnectAttempts++
this.events.emit('reconnecting', {
attempt: this.reconnectAttempts,
maxAttempts: this.maxReconnectAttempts,
})
// Exponential backoff
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connect().catch(error => {
this.events.emit('error', error as Error)
// Schedule next attempt if we haven't exceeded max attempts
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
})
}, delay)
}
}

236
src/service-host.ts Normal file
View File

@@ -0,0 +1,236 @@
import { WebRTCRondevuConnection } from './connection.js'
import { WebRTCContext } from './webrtc-context.js'
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './signaler.js'
import { NoOpSignaler } from './noop-signaler.js'
import { EventBus } from './event-bus.js'
import { createBin } from './bin.js'
import { ConnectionInterface } from './types.js'
export interface ServiceHostOptions {
service: string
rondevuService: RondevuService
maxPeers?: number
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
}
export interface ServiceHostEvents {
connection: ConnectionInterface
'connection-closed': { connectionId: string; reason: string }
error: Error
}
/**
* ServiceHost - Manages a pool of WebRTC offers for a service
*
* Maintains up to maxPeers concurrent offers, automatically replacing
* them when connections are established or expire.
*
* @example
* ```typescript
* const rondevuService = new RondevuService({
* apiUrl: 'https://signal.example.com',
* username: 'myusername',
* })
*
* await rondevuService.initialize()
* await rondevuService.claimUsername()
*
* const host = new ServiceHost({
* service: 'chat.app@1.0.0',
* rondevuService,
* maxPeers: 5,
* })
*
* await host.start()
*
* host.events.on('connection', (conn) => {
* console.log('New connection:', conn.id)
* conn.events.on('message', (msg) => {
* console.log('Message:', msg)
* })
* })
* ```
*/
export class ServiceHost {
private connections = new Map<string, WebRTCRondevuConnection>()
private readonly service: string
private readonly rondevuService: RondevuService
private readonly maxPeers: number
private readonly ttl: number
private readonly isPublic: boolean
private readonly metadata?: Record<string, any>
private readonly bin = createBin()
private isStarted = false
public readonly events = new EventBus<ServiceHostEvents>()
constructor(options: ServiceHostOptions) {
this.service = options.service
this.rondevuService = options.rondevuService
this.maxPeers = options.maxPeers || 20
this.ttl = options.ttl || 300000
this.isPublic = options.isPublic !== false
this.metadata = options.metadata
}
/**
* Start hosting the service - creates initial pool of offers
*/
async start(): Promise<void> {
if (this.isStarted) {
throw new Error('ServiceHost already started')
}
this.isStarted = true
await this.fillOfferPool()
}
/**
* Stop hosting - closes all connections and cleans up
*/
stop(): void {
this.isStarted = false
this.connections.forEach(conn => conn.disconnect())
this.connections.clear()
this.bin.clean()
}
/**
* Get current number of active connections
*/
getConnectionCount(): number {
return Array.from(this.connections.values()).filter(conn => conn.state === 'connected')
.length
}
/**
* Get current number of pending offers
*/
getPendingOfferCount(): number {
return Array.from(this.connections.values()).filter(conn => conn.state === 'connecting')
.length
}
/**
* Fill the offer pool up to maxPeers
*/
private async fillOfferPool(): Promise<void> {
const currentOffers = this.connections.size
const needed = this.maxPeers - currentOffers
if (needed <= 0) {
return
}
// Create multiple offers in parallel
const offerPromises: Promise<void>[] = []
for (let i = 0; i < needed; i++) {
offerPromises.push(this.createOffer())
}
await Promise.allSettled(offerPromises)
}
/**
* Create a single offer and publish it
*/
private async createOffer(): Promise<void> {
try {
// Create temporary context with NoOp signaler
const tempContext = new WebRTCContext(new NoOpSignaler())
// Create connection (offerer role)
const conn = new WebRTCRondevuConnection({
id: `${this.service}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
service: this.service,
offer: null,
context: tempContext,
})
// Wait for offer to be created
await conn.ready
// Get offer SDP
if (!conn.connection?.localDescription?.sdp) {
throw new Error('Failed to create offer SDP')
}
const sdp = conn.connection.localDescription.sdp
// Publish service offer
const service = await this.rondevuService.publishService({
serviceFqn: this.service,
sdp,
ttl: this.ttl,
isPublic: this.isPublic,
metadata: this.metadata,
})
// Replace with real signaler now that we have offerId
const realSignaler = new RondevuSignaler(this.rondevuService.getAPI(), service.offerId)
;(tempContext as any).signaler = realSignaler
// Track connection
this.connections.set(conn.id, conn)
// Listen for state changes
const cleanup = conn.events.on('state-change', state => {
this.handleConnectionStateChange(conn, state)
})
this.bin(cleanup)
} catch (error) {
this.events.emit('error', error as Error)
}
}
/**
* Handle connection state changes
*/
private handleConnectionStateChange(
conn: WebRTCRondevuConnection,
state: ConnectionInterface['state']
): void {
if (state === 'connected') {
// Connection established - emit event
this.events.emit('connection', conn)
// Create new offer to replace this one
if (this.isStarted) {
this.fillOfferPool().catch(error => {
this.events.emit('error', error as Error)
})
}
} else if (state === 'disconnected') {
// Connection closed - remove and create new offer
this.connections.delete(conn.id)
this.events.emit('connection-closed', {
connectionId: conn.id,
reason: state,
})
if (this.isStarted) {
this.fillOfferPool().catch(error => {
this.events.emit('error', error as Error)
})
}
}
}
/**
* Get all active connections
*/
getConnections(): WebRTCRondevuConnection[] {
return Array.from(this.connections.values())
}
/**
* Get a specific connection by ID
*/
getConnection(connectionId: string): WebRTCRondevuConnection | undefined {
return this.connections.get(connectionId)
}
}

View File

@@ -1,6 +1,6 @@
import {Signaler} from "./types";
import {Binnable} from "./bin";
import {RondevuAPI} from "./api";
import { Signaler } from './types.js'
import { Binnable } from './bin.js'
import { RondevuAPI } from './api.js'
/**
* RondevuSignaler - Handles ICE candidate exchange via Rondevu API
@@ -12,18 +12,31 @@ export class RondevuSignaler implements Signaler {
private offerId: string
) {}
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
throw new Error('Method not implemented.')
}
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
throw new Error('Method not implemented.')
}
setOffer(offer: RTCSessionDescriptionInit): Promise<void> {
throw new Error('Method not implemented.')
}
setAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
throw new Error('Method not implemented.')
}
/**
* Send local ICE candidate to signaling server
* Send a local ICE candidate to signaling server
*/
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
const candidateData = candidate.toJSON();
const candidateData = candidate.toJSON()
// Skip empty candidates
if (!candidateData.candidate || candidateData.candidate === '') {
return;
return
}
await this.api.addIceCandidates(this.offerId, [candidateData]);
await this.api.addIceCandidates(this.offerId, [candidateData])
}
/**
@@ -31,52 +44,61 @@ export class RondevuSignaler implements Signaler {
* Returns cleanup function to stop polling
*/
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
let lastTimestamp = 0;
let polling = true;
let lastTimestamp = 0
let polling = true
const poll = async () => {
while (polling) {
try {
const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp);
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 !== '') {
if (
item.candidate &&
item.candidate.candidate &&
item.candidate.candidate !== ''
) {
try {
const rtcCandidate = new RTCIceCandidate(item.candidate);
callback(rtcCandidate);
lastTimestamp = item.createdAt;
const rtcCandidate = new RTCIceCandidate(item.candidate)
callback(rtcCandidate)
lastTimestamp = item.createdAt
} catch (err) {
console.warn('Failed to process ICE candidate:', err);
lastTimestamp = item.createdAt;
console.warn('Failed to process ICE candidate:', err)
lastTimestamp = item.createdAt
}
} else {
lastTimestamp = item.createdAt;
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;
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);
console.error('Error polling for ICE candidates:', err)
}
// Poll every second
if (polling) {
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
};
}
// Start polling in background
poll();
// Start polling in the background
poll().then(() => {
console.log('ICE polling started')
})
// Return cleanup function
return () => {
polling = false;
};
polling = false
}
}
}
}

View File

@@ -1,34 +1,42 @@
/**
* Core connection types
*/
import {EventBus} from "./event-bus";
import {Binnable} from "./bin";
import { EventBus } from './event-bus.js'
import { Binnable } from './bin.js'
export type Message = string | ArrayBuffer;
export type Message = string | ArrayBuffer
export interface QueueMessageOptions {
expiresAt?: number;
expiresAt?: number
}
export interface ConnectionEvents {
'state-change': ConnectionInterface['state']
'message': Message;
message: Message
}
export interface ConnectionInterface {
id: string;
host: string;
service: string;
state: 'connected' | 'disconnected' | 'connecting';
lastActive: number;
expiresAt?: number;
events: EventBus<ConnectionEvents>;
export const ConnectionStates = ['connected', 'disconnected', 'connecting'] as const
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>;
sendMessage(message: Message): Promise<boolean>;
export const isConnectionState = (state: string): state is (typeof ConnectionStates)[number] =>
ConnectionStates.includes(state as any)
export interface ConnectionInterface {
id: string
service: string
state: (typeof ConnectionStates)[number]
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;
}
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
}

35
src/webrtc-context.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Signaler } from './types'
export class WebRTCContext {
constructor(public readonly signaler: Signaler) {}
createPeerConnection(): RTCPeerConnection {
return new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.relay.metered.ca:80',
},
{
urls: 'turn:standard.relay.metered.ca:80',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turn:standard.relay.metered.ca:80?transport=tcp',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turn:standard.relay.metered.ca:443',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turns:standard.relay.metered.ca:443?transport=tcp',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
],
})
}
}