8 Commits

Author SHA1 Message Date
b2d42fa776 fix: use async ed25519 functions (signAsync, getPublicKeyAsync)
The sync ed25519 functions (sign, getPublicKey) require hashes.sha512,
but WebCrypto only provides async digest. Switch to using the async
ed25519 API which works with hashes.sha512Async.

This fixes the "hashes.sha512 not set" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:19:05 +01:00
63e14ddc5b fix: initialize SHA-512 hash function for @noble/ed25519 v3
@noble/ed25519 v3.0.0 requires explicit SHA-512 hash function setup
before using any cryptographic operations. This fixes the
"hashes.sha512 not set" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:44:03 +01:00
c9f6119148 fix: export V2 API classes and types from index
- Export RondevuUsername, RondevuServices, RondevuDiscovery classes
- Export all related type interfaces
- Export pool-related types (PoolStatus, PooledServiceHandle)

This fixes the issue where the V2 APIs were available as properties
on the Rondevu client instance but not accessible as standalone imports.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:37:33 +01:00
15f821f08a 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>
2025-12-05 18:26:23 +01:00
895e7765f9 Update README: Remove custom peer ID documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:22:58 +01:00
49d3984640 Remove custom peer ID feature for security
Always generate cryptographically random 128-bit peer IDs to prevent peer ID hijacking vulnerability. This ensures peer IDs are secure through collision resistance rather than relying on expiration-based protection.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:19:07 +01:00
6057c3c582 0.7.11 2025-11-22 17:34:11 +01:00
255fe42a43 Add optional info field to offers
- Add info field to CreateOfferRequest and Offer types
- Update README with info field examples and documentation
- Public metadata field visible in all API responses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:34:11 +01:00
13 changed files with 2023 additions and 539 deletions

921
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",
"version": "0.7.10",
"version": "0.7.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.7.10",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.5.1"
},
"devDependencies": {
"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": {
"version": "0.5.1",
"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",
"version": "0.7.10",
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
"version": "0.8.3",
"description": "TypeScript client for Rondevu DNS-like WebRTC with username claiming and service discovery",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -27,6 +27,6 @@
"README.md"
],
"dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1"
"@noble/ed25519": "^3.0.0"
}
}

View File

@@ -29,21 +29,16 @@ export class RondevuAuth {
/**
* Register a new peer and receive credentials
* @param customPeerId - Optional custom peer ID (1-128 characters). If not provided, a random ID will be generated.
* @throws Error if registration fails (e.g., peer ID already in use)
* Generates a cryptographically random peer ID (128-bit)
* @throws Error if registration fails
*/
async register(customPeerId?: string): Promise<Credentials> {
const body: { peerId?: string } = {};
if (customPeerId !== undefined) {
body.peerId = customPeerId;
}
async register(): Promise<Credentials> {
const response = await this.fetchFn(`${this.baseUrl}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
body: JSON.stringify({}),
});
if (!response.ok) {

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
} from './offers.js';
// Export bloom filter
export { BloomFilter } from './bloom.js';
// Export peer manager
export { default as RondevuPeer } from './peer/index.js';
export type {
@@ -30,3 +27,28 @@ export type {
PeerEvents,
PeerTimeouts
} from './peer/index.js';
// Export username API
export { RondevuUsername } from './usernames.js';
export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js';
// Export services API
export { RondevuServices } from './services.js';
export type {
ServicePublishResult,
PublishServiceOptions,
ServiceHandle
} from './services.js';
// Export discovery API
export { RondevuDiscovery } from './discovery.js';
export type {
ServiceInfo,
ServiceListResult,
ServiceQueryResult,
ServiceDetails,
ConnectResult
} from './discovery.js';
// Export pool types
export type { PoolStatus, PooledServiceHandle } from './service-pool.js';

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

@@ -9,6 +9,7 @@ export interface CreateOfferRequest {
topics: string[];
ttl?: number;
secret?: string;
info?: string;
}
export interface Offer {
@@ -21,6 +22,7 @@ export interface Offer {
lastSeen: number;
secret?: string;
hasSecret?: boolean;
info?: string;
answererPeerId?: string;
answerSdp?: string;
answeredAt?: number;

View File

@@ -1,5 +1,8 @@
import { RondevuAuth, Credentials, FetchFunction } from './auth.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';
export interface RondevuOptions {
@@ -65,7 +68,11 @@ export interface RondevuOptions {
export class Rondevu {
readonly auth: RondevuAuth;
readonly usernames: RondevuUsername;
private _offers?: RondevuOffers;
private _services?: RondevuServices;
private _discovery?: RondevuDiscovery;
private credentials?: Credentials;
private baseUrl: string;
private fetchFn?: FetchFunction;
@@ -81,15 +88,19 @@ export class Rondevu {
this.rtcIceCandidate = options.RTCIceCandidate;
this.auth = new RondevuAuth(this.baseUrl, this.fetchFn);
this.usernames = new RondevuUsername(this.baseUrl);
if (options.credentials) {
this.credentials = options.credentials;
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 {
if (!this._offers) {
@@ -99,18 +110,40 @@ export class Rondevu {
}
/**
* Register and initialize authenticated client
* @param customPeerId - Optional custom peer ID (1-128 characters). If not provided, a random ID will be generated.
* Get services API (requires authentication)
*/
async register(customPeerId?: string): Promise<Credentials> {
this.credentials = await this.auth.register(customPeerId);
get services(): RondevuServices {
if (!this._services) {
throw new Error('Not authenticated. Call register() first or provide credentials.');
}
return this._services;
}
// Create offers API instance
/**
* 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
* Generates a cryptographically random peer ID (128-bit)
*/
async register(): Promise<Credentials> {
this.credentials = await this.auth.register();
// Create API instances
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);
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] };
}
}

200
src/usernames.ts Normal file
View File

@@ -0,0 +1,200 @@
import * as ed25519 from '@noble/ed25519';
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
// Uses built-in WebCrypto API which only provides async digest
// We use the async ed25519 functions (signAsync, verifyAsync, getPublicKeyAsync)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
};
/**
* 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.getPublicKeyAsync(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.signAsync(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 };
}
}