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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 19:37:43 +01:00
parent 945d5a8792
commit 54355323d9
21 changed files with 5066 additions and 307 deletions

View File

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