feat: implement offer pooling for multi-connection services

- Add OfferPool class for managing multiple offers with auto-refill polling
- Add ServicePool class for orchestrating pooled connections and connection registry
- Modify exposeService() to support poolSize parameter (backward compatible)
- Add discovery API with service resolution and online status checking
- Add username claiming with Ed25519 signatures and TTL-based expiry
- Fix TypeScript import errors (RondevuPeer default export)
- Fix RondevuPeer instantiation to use RondevuOffers instance
- Fix peer.answer() calls to include required PeerOptions parameter
- Fix Ed25519 API call (randomSecretKey vs randomPrivateKey)
- Remove bloom filter (V1 legacy code)
- Update version to 0.8.0
- Document pooling feature and new APIs in README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-05 18:26:23 +01:00
parent 895e7765f9
commit 15f821f08a
11 changed files with 1981 additions and 518 deletions

923
README.md

File diff suppressed because it is too large Load Diff

14
package-lock.json generated
View File

@@ -1,20 +1,30 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.7.11", "version": "0.7.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.7.11", "version": "0.7.12",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.5.1" "@xtr-dev/rondevu-client": "^0.5.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@xtr-dev/rondevu-client": { "node_modules/@xtr-dev/rondevu-client": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.7.12", "version": "0.8.0",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server", "description": "TypeScript client for Rondevu DNS-like WebRTC with username claiming and service discovery",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -27,6 +27,6 @@
"README.md" "README.md"
], ],
"dependencies": { "dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1" "@noble/ed25519": "^3.0.0"
} }
} }

View File

@@ -1,83 +0,0 @@
// Declare Buffer for Node.js compatibility
declare const Buffer: any;
/**
* Simple bloom filter implementation for peer ID exclusion
* Uses multiple hash functions for better distribution
*/
export class BloomFilter {
private bits: Uint8Array;
private size: number;
private numHashes: number;
constructor(size: number = 1024, numHashes: number = 3) {
this.size = size;
this.numHashes = numHashes;
this.bits = new Uint8Array(Math.ceil(size / 8));
}
/**
* Add a peer ID to the filter
*/
add(peerId: string): void {
for (let i = 0; i < this.numHashes; i++) {
const hash = this.hash(peerId, i);
const index = hash % this.size;
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
this.bits[byteIndex] |= 1 << bitIndex;
}
}
/**
* Test if peer ID might be in the filter
*/
test(peerId: string): boolean {
for (let i = 0; i < this.numHashes; i++) {
const hash = this.hash(peerId, i);
const index = hash % this.size;
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
if (!(this.bits[byteIndex] & (1 << bitIndex))) {
return false;
}
}
return true;
}
/**
* Get raw bits for transmission
*/
toBytes(): Uint8Array {
return this.bits;
}
/**
* Convert to base64 for URL parameters
*/
toBase64(): string {
// Convert Uint8Array to regular array then to string
const binaryString = String.fromCharCode(...Array.from(this.bits));
// Use btoa for browser, or Buffer for Node.js
if (typeof btoa !== 'undefined') {
return btoa(binaryString);
} else if (typeof Buffer !== 'undefined') {
return Buffer.from(this.bits).toString('base64');
} else {
// Fallback: manual base64 encoding
throw new Error('No base64 encoding available');
}
}
/**
* Simple hash function (FNV-1a variant)
*/
private hash(str: string, seed: number): number {
let hash = 2166136261 ^ seed;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
}

276
src/discovery.ts Normal file
View File

@@ -0,0 +1,276 @@
import RondevuPeer from './peer/index.js';
import { RondevuOffers } from './offers.js';
/**
* Service info from discovery
*/
export interface ServiceInfo {
uuid: string;
isPublic: boolean;
serviceFqn?: string;
metadata?: Record<string, any>;
}
/**
* Service list result
*/
export interface ServiceListResult {
username: string;
services: ServiceInfo[];
}
/**
* Service query result
*/
export interface ServiceQueryResult {
uuid: string;
allowed: boolean;
}
/**
* Service details
*/
export interface ServiceDetails {
serviceId: string;
username: string;
serviceFqn: string;
offerId: string;
sdp: string;
isPublic: boolean;
metadata?: Record<string, any>;
createdAt: number;
expiresAt: number;
}
/**
* Connect result
*/
export interface ConnectResult {
peer: RondevuPeer;
channel: RTCDataChannel;
}
/**
* Rondevu Discovery API
* Handles service discovery and connections
*/
export class RondevuDiscovery {
private offersApi: RondevuOffers;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string }
) {
this.offersApi = new RondevuOffers(baseUrl, credentials);
}
/**
* Lists all services for a username
* Returns UUIDs only for private services, full details for public
*/
async listServices(username: string): Promise<ServiceListResult> {
const response = await fetch(`${this.baseUrl}/usernames/${username}/services`);
if (!response.ok) {
throw new Error('Failed to list services');
}
const data = await response.json();
return {
username: data.username,
services: data.services
};
}
/**
* Queries a service by FQN
* Returns UUID if service exists and is allowed
*/
async queryService(username: string, serviceFqn: string): Promise<ServiceQueryResult> {
const response = await fetch(`${this.baseUrl}/index/${username}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceFqn })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Service not found');
}
const data = await response.json();
return {
uuid: data.uuid,
allowed: data.allowed
};
}
/**
* Gets service details by UUID
*/
async getServiceDetails(uuid: string): Promise<ServiceDetails> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Service not found');
}
const data = await response.json();
return {
serviceId: data.serviceId,
username: data.username,
serviceFqn: data.serviceFqn,
offerId: data.offerId,
sdp: data.sdp,
isPublic: data.isPublic,
metadata: data.metadata,
createdAt: data.createdAt,
expiresAt: data.expiresAt
};
}
/**
* Connects to a service by UUID
*/
async connectToService(
uuid: string,
options?: {
rtcConfig?: RTCConfiguration;
onConnected?: () => void;
onData?: (data: any) => void;
}
): Promise<RondevuPeer> {
// Get service details
const service = await this.getServiceDetails(uuid);
// Create peer with the offer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Set up event handlers
if (options?.onConnected) {
peer.on('connected', options.onConnected);
}
if (options?.onData) {
peer.on('datachannel', (channel: RTCDataChannel) => {
channel.onmessage = (e) => options.onData!(e.data);
});
}
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
return peer;
}
/**
* Convenience method: Query and connect in one call
* Returns both peer and data channel
*/
async connect(
username: string,
serviceFqn: string,
options?: {
rtcConfig?: RTCConfiguration;
}
): Promise<ConnectResult> {
// Query service
const query = await this.queryService(username, serviceFqn);
if (!query.allowed) {
throw new Error('Service access denied');
}
// Get service details
const service = await this.getServiceDetails(query.uuid);
// Create peer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
// Wait for data channel
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for data channel'));
}, 30000);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
peer.on('failed', (error: Error) => {
clearTimeout(timeout);
reject(error);
});
});
return { peer, channel };
}
/**
* Convenience method: Connect to service by UUID with channel
*/
async connectByUuid(
uuid: string,
options?: { rtcConfig?: RTCConfiguration }
): Promise<ConnectResult> {
// Get service details
const service = await this.getServiceDetails(uuid);
// Create peer
const peer = new RondevuPeer(
this.offersApi,
options?.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
// Answer the offer
await peer.answer(service.offerId, service.sdp, {
topics: [], // V2 doesn't use topics
rtcConfig: options?.rtcConfig
});
// Wait for data channel
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for data channel'));
}, 30000);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
peer.on('failed', (error: Error) => {
clearTimeout(timeout);
reject(error);
});
});
return { peer, channel };
}
}

View File

@@ -20,9 +20,6 @@ export type {
TopicInfo TopicInfo
} from './offers.js'; } from './offers.js';
// Export bloom filter
export { BloomFilter } from './bloom.js';
// Export peer manager // Export peer manager
export { default as RondevuPeer } from './peer/index.js'; export { default as RondevuPeer } from './peer/index.js';
export type { export type {

174
src/offer-pool.ts Normal file
View File

@@ -0,0 +1,174 @@
import { RondevuOffers, Offer } from './offers.js';
/**
* Represents an offer that has been answered
*/
export interface AnsweredOffer {
offerId: string;
answererId: string;
sdp: string;
answeredAt: number;
}
/**
* Configuration options for the offer pool
*/
export interface OfferPoolOptions {
/** Number of simultaneous open offers to maintain */
poolSize: number;
/** Polling interval in milliseconds (default: 2000ms) */
pollingInterval?: number;
/** Callback invoked when an offer is answered */
onAnswered: (answer: AnsweredOffer) => Promise<void>;
/** Callback to create new offers when refilling the pool */
onRefill: (count: number) => Promise<Offer[]>;
/** Error handler for pool operations */
onError: (error: Error, context: string) => void;
}
/**
* Manages a pool of offers with automatic polling and refill
*
* The OfferPool maintains a configurable number of simultaneous offers,
* polls for answers periodically, and automatically refills the pool
* when offers are consumed.
*/
export class OfferPool {
private offers: Map<string, Offer> = new Map();
private polling: boolean = false;
private pollingTimer?: ReturnType<typeof setInterval>;
private lastPollTime: number = 0;
private readonly pollingInterval: number;
constructor(
private offersApi: RondevuOffers,
private options: OfferPoolOptions
) {
this.pollingInterval = options.pollingInterval || 2000;
}
/**
* Add offers to the pool
*/
async addOffers(offers: Offer[]): Promise<void> {
for (const offer of offers) {
this.offers.set(offer.id, offer);
}
}
/**
* Start polling for answers
*/
async start(): Promise<void> {
if (this.polling) {
return;
}
this.polling = true;
// Do an immediate poll
await this.poll().catch((error) => {
this.options.onError(error, 'initial-poll');
});
// Start polling interval
this.pollingTimer = setInterval(async () => {
if (this.polling) {
await this.poll().catch((error) => {
this.options.onError(error, 'poll');
});
}
}, this.pollingInterval);
}
/**
* Stop polling for answers
*/
async stop(): Promise<void> {
this.polling = false;
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = undefined;
}
}
/**
* Poll for answers and refill the pool if needed
*/
private async poll(): Promise<void> {
try {
// Get all answers from server
const answers = await this.offersApi.getAnswers();
// Filter for our pool's offers
const myAnswers = answers.filter(a => this.offers.has(a.offerId));
// Process each answer
for (const answer of myAnswers) {
// Notify ServicePool
await this.options.onAnswered({
offerId: answer.offerId,
answererId: answer.answererId,
sdp: answer.sdp,
answeredAt: answer.answeredAt
});
// Remove consumed offer from pool
this.offers.delete(answer.offerId);
}
// Immediate refill if below pool size
if (this.offers.size < this.options.poolSize) {
const needed = this.options.poolSize - this.offers.size;
try {
const newOffers = await this.options.onRefill(needed);
await this.addOffers(newOffers);
} catch (refillError) {
this.options.onError(
refillError as Error,
'refill'
);
}
}
this.lastPollTime = Date.now();
} catch (error) {
// Don't crash the pool on errors - let error handler deal with it
this.options.onError(error as Error, 'poll');
}
}
/**
* Get the current number of active offers in the pool
*/
getActiveOfferCount(): number {
return this.offers.size;
}
/**
* Get all active offer IDs
*/
getActiveOfferIds(): string[] {
return Array.from(this.offers.keys());
}
/**
* Get the last poll timestamp
*/
getLastPollTime(): number {
return this.lastPollTime;
}
/**
* Check if the pool is currently polling
*/
isPolling(): boolean {
return this.polling;
}
}

View File

@@ -1,5 +1,8 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.js'; import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
import { RondevuOffers } from './offers.js'; import { RondevuOffers } from './offers.js';
import { RondevuUsername } from './usernames.js';
import { RondevuServices } from './services.js';
import { RondevuDiscovery } from './discovery.js';
import RondevuPeer from './peer/index.js'; import RondevuPeer from './peer/index.js';
export interface RondevuOptions { export interface RondevuOptions {
@@ -65,7 +68,11 @@ export interface RondevuOptions {
export class Rondevu { export class Rondevu {
readonly auth: RondevuAuth; readonly auth: RondevuAuth;
readonly usernames: RondevuUsername;
private _offers?: RondevuOffers; private _offers?: RondevuOffers;
private _services?: RondevuServices;
private _discovery?: RondevuDiscovery;
private credentials?: Credentials; private credentials?: Credentials;
private baseUrl: string; private baseUrl: string;
private fetchFn?: FetchFunction; private fetchFn?: FetchFunction;
@@ -81,15 +88,19 @@ export class Rondevu {
this.rtcIceCandidate = options.RTCIceCandidate; this.rtcIceCandidate = options.RTCIceCandidate;
this.auth = new RondevuAuth(this.baseUrl, this.fetchFn); this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
this.usernames = new RondevuUsername(this.baseUrl);
if (options.credentials) { if (options.credentials) {
this.credentials = options.credentials; this.credentials = options.credentials;
this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn); this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn);
this._services = new RondevuServices(this.baseUrl, this.credentials);
this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials);
} }
} }
/** /**
* Get offers API (requires authentication) * Get offers API (low-level access, requires authentication)
* For most use cases, use services and discovery APIs instead
*/ */
get offers(): RondevuOffers { get offers(): RondevuOffers {
if (!this._offers) { if (!this._offers) {
@@ -98,6 +109,26 @@ export class Rondevu {
return this._offers; return this._offers;
} }
/**
* Get services API (requires authentication)
*/
get services(): RondevuServices {
if (!this._services) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._services;
}
/**
* Get discovery API (requires authentication)
*/
get discovery(): RondevuDiscovery {
if (!this._discovery) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._discovery;
}
/** /**
* Register and initialize authenticated client * Register and initialize authenticated client
* Generates a cryptographically random peer ID (128-bit) * Generates a cryptographically random peer ID (128-bit)
@@ -105,12 +136,14 @@ export class Rondevu {
async register(): Promise<Credentials> { async register(): Promise<Credentials> {
this.credentials = await this.auth.register(); this.credentials = await this.auth.register();
// Create offers API instance // Create API instances
this._offers = new RondevuOffers( this._offers = new RondevuOffers(
this.baseUrl, this.baseUrl,
this.credentials, this.credentials,
this.fetchFn this.fetchFn
); );
this._services = new RondevuServices(this.baseUrl, this.credentials);
this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials);
return this.credentials; return this.credentials;
} }

490
src/service-pool.ts Normal file
View File

@@ -0,0 +1,490 @@
import { RondevuOffers, Offer } from './offers.js';
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { OfferPool, AnsweredOffer } from './offer-pool.js';
import { ServiceHandle } from './services.js';
/**
* Connection information for tracking active connections
*/
interface ConnectionInfo {
peer: RondevuPeer;
channel: RTCDataChannel;
connectedAt: number;
offerId: string;
}
/**
* Status information about the pool
*/
export interface PoolStatus {
/** Number of active offers in the pool */
activeOffers: number;
/** Number of currently connected peers */
activeConnections: number;
/** Total number of connections handled since start */
totalConnectionsHandled: number;
/** Number of failed offer creation attempts */
failedOfferCreations: number;
}
/**
* Configuration options for a pooled service
*/
export interface ServicePoolOptions {
/** Username that owns the service */
username: string;
/** Private key for signing service operations */
privateKey: string;
/** Fully qualified service name (e.g., com.example.chat@1.0.0) */
serviceFqn: string;
/** WebRTC configuration */
rtcConfig?: RTCConfiguration;
/** Whether the service is publicly discoverable */
isPublic?: boolean;
/** Optional metadata for the service */
metadata?: Record<string, any>;
/** Time-to-live for offers in milliseconds */
ttl?: number;
/** Handler invoked for each new connection */
handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId: string) => void;
/** Number of simultaneous open offers to maintain (default: 1) */
poolSize?: number;
/** Polling interval in milliseconds (default: 2000ms) */
pollingInterval?: number;
/** Callback for pool status updates */
onPoolStatus?: (status: PoolStatus) => void;
/** Error handler for pool operations */
onError?: (error: Error, context: string) => void;
}
/**
* Extended service handle with pool-specific methods
*/
export interface PooledServiceHandle extends ServiceHandle {
/** Get current pool status */
getStatus: () => PoolStatus;
/** Manually add offers to the pool */
addOffers: (count: number) => Promise<void>;
}
/**
* Manages a pooled service with multiple concurrent connections
*
* ServicePool coordinates offer creation, answer polling, and connection
* management for services that need to handle multiple simultaneous connections.
*/
export class ServicePool {
private offerPool?: OfferPool;
private connections: Map<string, ConnectionInfo> = new Map();
private status: PoolStatus = {
activeOffers: 0,
activeConnections: 0,
totalConnectionsHandled: 0,
failedOfferCreations: 0
};
private serviceId?: string;
private uuid?: string;
private offersApi: RondevuOffers;
private usernameApi: RondevuUsername;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string },
private options: ServicePoolOptions
) {
this.offersApi = new RondevuOffers(baseUrl, credentials);
this.usernameApi = new RondevuUsername(baseUrl);
}
/**
* Start the pooled service
*/
async start(): Promise<PooledServiceHandle> {
const poolSize = this.options.poolSize || 1;
// 1. Create initial service (publishes first offer)
const service = await this.publishInitialService();
this.serviceId = service.serviceId;
this.uuid = service.uuid;
// 2. Create additional offers for pool (poolSize - 1)
const additionalOffers: Offer[] = [];
if (poolSize > 1) {
try {
const offers = await this.createOffers(poolSize - 1);
additionalOffers.push(...offers);
} catch (error) {
this.handleError(error as Error, 'initial-offer-creation');
}
}
// 3. Initialize OfferPool with all offers
this.offerPool = new OfferPool(this.offersApi, {
poolSize,
pollingInterval: this.options.pollingInterval || 2000,
onAnswered: (answer) => this.handleConnection(answer),
onRefill: (count) => this.createOffers(count),
onError: (err, ctx) => this.handleError(err, ctx)
});
// Add all offers to pool
const allOffers = [
{ id: service.offerId, peerId: this.credentials.peerId, sdp: '', topics: [], expiresAt: service.expiresAt, lastSeen: Date.now() },
...additionalOffers
];
await this.offerPool.addOffers(allOffers);
// 4. Start polling
await this.offerPool.start();
// Update status
this.updateStatus();
// 5. Return handle
return {
serviceId: this.serviceId,
uuid: this.uuid,
offerId: service.offerId,
unpublish: () => this.stop(),
getStatus: () => this.getStatus(),
addOffers: (count) => this.manualRefill(count)
};
}
/**
* Stop the pooled service and clean up
*/
async stop(): Promise<void> {
// 1. Stop accepting new connections
if (this.offerPool) {
await this.offerPool.stop();
}
// 2. Delete remaining offers
if (this.offerPool) {
const offerIds = this.offerPool.getActiveOfferIds();
await Promise.allSettled(
offerIds.map(id => this.offersApi.delete(id).catch(() => {}))
);
}
// 3. Close active connections
const closePromises = Array.from(this.connections.values()).map(
async (conn) => {
try {
// Give a brief moment for graceful closure
await new Promise(resolve => setTimeout(resolve, 100));
conn.peer.pc.close();
} catch {
// Ignore errors during cleanup
}
}
);
await Promise.allSettled(closePromises);
// 4. Delete service if we have a serviceId
if (this.serviceId) {
try {
const response = await fetch(`${this.baseUrl}/services/${this.serviceId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({ username: this.options.username })
});
if (!response.ok) {
console.error('Failed to delete service:', await response.text());
}
} catch (error) {
console.error('Error deleting service:', error);
}
}
// Clear all state
this.connections.clear();
this.offerPool = undefined;
}
/**
* Handle an answered offer by setting up the connection
*/
private async handleConnection(answer: AnsweredOffer): Promise<void> {
const connectionId = this.generateConnectionId();
try {
// Create peer connection
const peer = new RondevuPeer(
this.offersApi,
this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
peer.role = 'offerer';
peer.offerId = answer.offerId;
// Set remote description (the answer)
await peer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
// Wait for data channel (answerer creates it, we receive it)
const channel = await new Promise<RTCDataChannel>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timeout waiting for data channel')),
30000
);
peer.on('datachannel', (ch: RTCDataChannel) => {
clearTimeout(timeout);
resolve(ch);
});
// Also check if channel already exists
if (peer.pc.ondatachannel) {
const existingHandler = peer.pc.ondatachannel;
peer.pc.ondatachannel = (event) => {
clearTimeout(timeout);
resolve(event.channel);
if (existingHandler) existingHandler.call(peer.pc, event);
};
} else {
peer.pc.ondatachannel = (event) => {
clearTimeout(timeout);
resolve(event.channel);
};
}
});
// Register connection
this.connections.set(connectionId, {
peer,
channel,
connectedAt: Date.now(),
offerId: answer.offerId
});
this.status.activeConnections++;
this.status.totalConnectionsHandled++;
// Setup cleanup on disconnect
peer.on('disconnected', () => {
this.connections.delete(connectionId);
this.status.activeConnections--;
this.updateStatus();
});
peer.on('failed', () => {
this.connections.delete(connectionId);
this.status.activeConnections--;
this.updateStatus();
});
// Update status
this.updateStatus();
// Invoke user handler (wrapped in try-catch)
try {
this.options.handler(channel, peer, connectionId);
} catch (handlerError) {
this.handleError(handlerError as Error, 'handler');
}
} catch (error) {
this.handleError(error as Error, 'connection-setup');
}
}
/**
* Create multiple offers
*/
private async createOffers(count: number): Promise<Offer[]> {
if (count <= 0) {
return [];
}
// Server supports max 10 offers per request
const batchSize = Math.min(count, 10);
const offers: Offer[] = [];
try {
// Create peer connections and generate offers
const offerRequests = [];
for (let i = 0; i < batchSize; i++) {
const pc = new RTCPeerConnection(this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Create data channel (required for offers)
pc.createDataChannel('rondevu-service');
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate SDP');
}
offerRequests.push({
sdp: offer.sdp,
topics: [], // V2 doesn't use topics
ttl: this.options.ttl
});
// Close the PC immediately - we only needed the SDP
pc.close();
}
// Batch create offers
const createdOffers = await this.offersApi.create(offerRequests);
offers.push(...createdOffers);
} catch (error) {
this.status.failedOfferCreations++;
this.handleError(error as Error, 'offer-creation');
throw error;
}
return offers;
}
/**
* Publish the initial service (creates first offer)
*/
private async publishInitialService(): Promise<{
serviceId: string;
uuid: string;
offerId: string;
expiresAt: number;
}> {
const { username, privateKey, serviceFqn, rtcConfig, isPublic, metadata, ttl } = this.options;
// Create peer connection for initial offer
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.createDataChannel('rondevu-service');
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate SDP');
}
// Create signature
const timestamp = Date.now();
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
const signature = await this.usernameApi.signMessage(message, privateKey);
// Publish service
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({
username,
serviceFqn,
sdp: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
pc.close();
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to publish service');
}
const data = await response.json();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
expiresAt: data.expiresAt
};
}
/**
* Manually add offers to the pool
*/
private async manualRefill(count: number): Promise<void> {
if (!this.offerPool) {
throw new Error('Pool not started');
}
const offers = await this.createOffers(count);
await this.offerPool.addOffers(offers);
this.updateStatus();
}
/**
* Get current pool status
*/
private getStatus(): PoolStatus {
return { ...this.status };
}
/**
* Update status and notify listeners
*/
private updateStatus(): void {
if (this.offerPool) {
this.status.activeOffers = this.offerPool.getActiveOfferCount();
}
if (this.options.onPoolStatus) {
this.options.onPoolStatus(this.getStatus());
}
}
/**
* Handle errors
*/
private handleError(error: Error, context: string): void {
if (this.options.onError) {
this.options.onError(error, context);
} else {
console.error(`ServicePool error (${context}):`, error);
}
}
/**
* Generate a unique connection ID
*/
private generateConnectionId(): string {
return `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}

308
src/services.ts Normal file
View File

@@ -0,0 +1,308 @@
import { RondevuUsername } from './usernames.js';
import RondevuPeer from './peer/index.js';
import { RondevuOffers } from './offers.js';
import { ServicePool, ServicePoolOptions, PooledServiceHandle, PoolStatus } from './service-pool.js';
/**
* Service publish result
*/
export interface ServicePublishResult {
serviceId: string;
uuid: string;
offerId: string;
expiresAt: number;
}
/**
* Service publish options
*/
export interface PublishServiceOptions {
username: string;
privateKey: string;
serviceFqn: string;
rtcConfig?: RTCConfiguration;
isPublic?: boolean;
metadata?: Record<string, any>;
ttl?: number;
onConnection?: (peer: RondevuPeer) => void;
}
/**
* Service handle for managing an exposed service
*/
export interface ServiceHandle {
serviceId: string;
uuid: string;
offerId: string;
unpublish: () => Promise<void>;
}
/**
* Rondevu Services API
* Handles service publishing and management
*/
export class RondevuServices {
private usernameApi: RondevuUsername;
private offersApi: RondevuOffers;
constructor(
private baseUrl: string,
private credentials: { peerId: string; secret: string }
) {
this.usernameApi = new RondevuUsername(baseUrl);
this.offersApi = new RondevuOffers(baseUrl, credentials);
}
/**
* Publishes a service
*/
async publishService(options: PublishServiceOptions): Promise<ServicePublishResult> {
const {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic = false,
metadata,
ttl
} = options;
// Validate FQN format
this.validateServiceFqn(serviceFqn);
// Create WebRTC peer connection to generate offer
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Add a data channel (required for datachannel-based services)
pc.createDataChannel('rondevu-service');
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
throw new Error('Failed to generate SDP');
}
// Create signature for username verification
const timestamp = Date.now();
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
const signature = await this.usernameApi.signMessage(message, privateKey);
// Publish service
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({
username,
serviceFqn,
sdp: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
pc.close();
throw new Error(error.error || 'Failed to publish service');
}
const data = await response.json();
// Close the connection for now (would be kept open in a real implementation)
pc.close();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
expiresAt: data.expiresAt
};
}
/**
* Unpublishes a service
*/
async unpublishService(serviceId: string, username: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/services/${serviceId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({ username })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to unpublish service');
}
}
/**
* Exposes a service with an automatic connection handler
* This is a convenience method that publishes the service and manages connections
*
* Set poolSize > 1 to enable offer pooling for handling multiple concurrent connections
*/
async exposeService(options: Omit<PublishServiceOptions, 'onConnection'> & {
handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId?: string) => void;
poolSize?: number;
pollingInterval?: number;
onPoolStatus?: (status: PoolStatus) => void;
onError?: (error: Error, context: string) => void;
}): Promise<ServiceHandle | PooledServiceHandle> {
const {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic,
metadata,
ttl,
handler,
poolSize,
pollingInterval,
onPoolStatus,
onError
} = options;
// If poolSize > 1, use pooled implementation
if (poolSize && poolSize > 1) {
const pool = new ServicePool(this.baseUrl, this.credentials, {
username,
privateKey,
serviceFqn,
rtcConfig,
isPublic,
metadata,
ttl,
handler: (channel, peer, connectionId) => handler(channel, peer, connectionId),
poolSize,
pollingInterval,
onPoolStatus,
onError
});
return await pool.start();
}
// Otherwise, use existing single-offer logic (UNCHANGED)
// Validate FQN
this.validateServiceFqn(serviceFqn);
// Create peer connection
const pc = new RTCPeerConnection(rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Create data channel
const channel = pc.createDataChannel('rondevu-service');
// Set up handler
channel.onopen = () => {
const peer = new RondevuPeer(
this.offersApi,
rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
);
handler(channel, peer);
};
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (!offer.sdp) {
pc.close();
throw new Error('Failed to generate SDP');
}
// Create signature
const timestamp = Date.now();
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
const signature = await this.usernameApi.signMessage(message, privateKey);
// Publish service
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
},
body: JSON.stringify({
username,
serviceFqn,
sdp: offer.sdp,
ttl,
isPublic,
metadata,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
pc.close();
throw new Error(error.error || 'Failed to expose service');
}
const data = await response.json();
return {
serviceId: data.serviceId,
uuid: data.uuid,
offerId: data.offerId,
unpublish: () => this.unpublishService(data.serviceId, username)
};
}
/**
* Validates service FQN format
*/
private validateServiceFqn(fqn: string): void {
const parts = fqn.split('@');
if (parts.length !== 2) {
throw new Error('Service FQN must be in format: service-name@version');
}
const [serviceName, version] = parts;
// Validate service name (reverse domain notation)
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
if (!serviceNameRegex.test(serviceName)) {
throw new Error('Service name must be reverse domain notation (e.g., com.example.service)');
}
if (serviceName.length < 3 || serviceName.length > 128) {
throw new Error('Service name must be 3-128 characters');
}
// Validate version (semantic versioning)
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
if (!versionRegex.test(version)) {
throw new Error('Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)');
}
}
/**
* Parses a service FQN into name and version
*/
parseServiceFqn(fqn: string): { name: string; version: string } {
const parts = fqn.split('@');
if (parts.length !== 2) {
throw new Error('Invalid FQN format');
}
return { name: parts[0], version: parts[1] };
}
}

193
src/usernames.ts Normal file
View File

@@ -0,0 +1,193 @@
import * as ed25519 from '@noble/ed25519';
/**
* Username claim result
*/
export interface UsernameClaimResult {
username: string;
publicKey: string;
privateKey: string;
claimedAt: number;
expiresAt: number;
}
/**
* Username availability check result
*/
export interface UsernameCheckResult {
username: string;
available: boolean;
claimedAt?: number;
expiresAt?: number;
publicKey?: string;
}
/**
* Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join('');
return btoa(binString);
}
/**
* Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64);
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
}
/**
* Rondevu Username API
* Handles username claiming with Ed25519 cryptographic proof
*/
export class RondevuUsername {
constructor(private baseUrl: string) {}
/**
* Generates an Ed25519 keypair for username claiming
*/
async generateKeypair(): Promise<{ publicKey: string; privateKey: string }> {
const privateKey = ed25519.utils.randomSecretKey();
const publicKey = await ed25519.getPublicKey(privateKey);
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey)
};
}
/**
* Signs a message with an Ed25519 private key
*/
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.sign(messageBytes, privateKey);
return bytesToBase64(signature);
}
/**
* Claims a username
* Generates a new keypair if one is not provided
*/
async claimUsername(
username: string,
existingKeypair?: { publicKey: string; privateKey: string }
): Promise<UsernameClaimResult> {
// Generate or use existing keypair
const keypair = existingKeypair || await this.generateKeypair();
// Create signed message
const timestamp = Date.now();
const message = `claim:${username}:${timestamp}`;
const signature = await this.signMessage(message, keypair.privateKey);
// Send claim request
const response = await fetch(`${this.baseUrl}/usernames/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
publicKey: keypair.publicKey,
signature,
message
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to claim username');
}
const data = await response.json();
return {
username: data.username,
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
claimedAt: data.claimedAt,
expiresAt: data.expiresAt
};
}
/**
* Checks if a username is available
*/
async checkUsername(username: string): Promise<UsernameCheckResult> {
const response = await fetch(`${this.baseUrl}/usernames/${username}`);
if (!response.ok) {
throw new Error('Failed to check username');
}
const data = await response.json();
return {
username: data.username,
available: data.available,
claimedAt: data.claimedAt,
expiresAt: data.expiresAt,
publicKey: data.publicKey
};
}
/**
* Helper: Save keypair to localStorage
* WARNING: This stores the private key in localStorage which is not the most secure
* For production use, consider using IndexedDB with encryption or hardware security modules
*/
saveKeypairToStorage(username: string, publicKey: string, privateKey: string): void {
const data = { username, publicKey, privateKey, savedAt: Date.now() };
localStorage.setItem(`rondevu:keypair:${username}`, JSON.stringify(data));
}
/**
* Helper: Load keypair from localStorage
*/
loadKeypairFromStorage(username: string): { publicKey: string; privateKey: string } | null {
const stored = localStorage.getItem(`rondevu:keypair:${username}`);
if (!stored) return null;
try {
const data = JSON.parse(stored);
return { publicKey: data.publicKey, privateKey: data.privateKey };
} catch {
return null;
}
}
/**
* Helper: Delete keypair from localStorage
*/
deleteKeypairFromStorage(username: string): void {
localStorage.removeItem(`rondevu:keypair:${username}`);
}
/**
* Export keypair as JSON string (for backup)
*/
exportKeypair(publicKey: string, privateKey: string): string {
return JSON.stringify({
publicKey,
privateKey,
exportedAt: Date.now()
});
}
/**
* Import keypair from JSON string
*/
importKeypair(json: string): { publicKey: string; privateKey: string } {
const data = JSON.parse(json);
if (!data.publicKey || !data.privateKey) {
throw new Error('Invalid keypair format');
}
return { publicKey: data.publicKey, privateKey: data.privateKey };
}
}