mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 02:43:25 +00:00
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:
295
src/api.ts
295
src/api.ts
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
49
src/bin.ts
49
src/bin.ts
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* ConnectionManager - Manages WebRTC peer connections
|
||||
*/
|
||||
|
||||
export class ConnectionManager {
|
||||
constructor() {
|
||||
// TODO: Initialize connection manager
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
110
src/event-bus.ts
110
src/event-bus.ts
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
src/index.ts
49
src/index.ts
@@ -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
35
src/noop-signaler.ts
Normal 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
168
src/rondevu-service.ts
Normal 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
244
src/service-client.ts
Normal 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
236
src/service-host.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
src/types.ts
44
src/types.ts
@@ -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
35
src/webrtc-context.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user