15 Commits

Author SHA1 Message Date
Bas
ecd6be7f8a Merge pull request #7 from xtr-dev/claude/fix-issue-6-CTKj9
Fix issue #6
2025-12-14 13:35:19 +01:00
Claude
e652fdc130 Fix duplicate answer processing race condition (#6)
Add polling guard to prevent concurrent pollInternal() execution.
The setInterval callback doesn't await the async pollInternal(),
which could cause multiple polls to process the same answer before
lastPollTimestamp is updated, resulting in "Called in wrong state:
stable" errors from setRemoteDescription().
2025-12-14 11:59:43 +00:00
68c3ffb4ea Add DX improvements and EventEmitter support (v0.18.1)
This release introduces several developer experience improvements:

Breaking Changes:
- Add EventEmitter support - Rondevu now extends EventEmitter
- Consolidate discovery methods into findService() (getService, discoverService, discoverServices methods still exist but findService is the new unified API)

New Features:
- EventEmitter lifecycle events:
  - offer:created (offerId, serviceFqn)
  - offer:answered (offerId, peerUsername)
  - connection:opened (offerId, dataChannel)
  - connection:closed (offerId)
  - ice:candidate:local (offerId, candidate) - locally generated ICE
  - ice:candidate:remote (offerId, candidate, role) - remote ICE from server
  - error (error, context)

- Unified findService() method with modes:
  - 'direct' - direct lookup by FQN with username
  - 'random' - random discovery without username
  - 'paginated' - paginated results with limit/offset

- Typed error classes for better error handling:
  - RondevuError (base class with context)
  - NetworkError (network/API failures)
  - ValidationError (input validation)
  - ConnectionError (WebRTC connection issues)

- Convenience methods:
  - getOfferCount() - get active offer count
  - isConnected(offerId) - check connection status
  - disconnectAll() - close all connections
  - getServiceStatus() - get service state

Type Exports:
- Export ActiveOffer interface for getActiveOffers() typing
- Export FindServiceOptions, ServiceResult, PaginatedServiceResult
- Export all error classes

Dependencies:
- Add @types/node for EventEmitter support

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 12:03:41 +01:00
02e8e971be 0.18.1 2025-12-14 12:03:41 +01:00
Bas
1ca9a91056 Merge pull request #5 from xtr-dev/claude/fix-issue-2-6zYvD
Refactor OfferFactory to receive pc from Rondevu
2025-12-14 11:24:54 +01:00
claude[bot]
a0dc9ddad0 Address code review suggestions
- Update README.md example to match new OfferFactory signature
- Add error handling and RTCPeerConnection cleanup on factory failure
- Document setupIceCandidateHandler() method usage
- Use undefined instead of null for offerId variable

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-12-14 10:21:43 +00:00
Claude
df231c192d Refactor OfferFactory to receive pc from Rondevu
Change the OfferFactory signature to receive the RTCPeerConnection as a
parameter instead of rtcConfig. This allows Rondevu to:

1. Create the RTCPeerConnection itself
2. Set up ICE candidate handlers BEFORE the factory runs
3. Ensure no candidates are lost when setLocalDescription() triggers
   ICE gathering

This is a cleaner fix for #2 that eliminates the race condition at the
source rather than working around it with queuing.

BREAKING CHANGE: OfferFactory signature changed from
  (rtcConfig: RTCConfiguration) => Promise<OfferContext>
to
  (pc: RTCPeerConnection) => Promise<OfferContext>

OfferContext no longer includes 'pc' since it's now provided by Rondevu.
2025-12-14 10:10:12 +00:00
Bas
4d90cce9d0 Merge pull request #3 from xtr-dev/claude/fix-issue-2-6zYvD
Fix issue #2
2025-12-14 11:03:47 +01:00
Bas
f5e202384a Merge pull request #4 from xtr-dev/add-claude-github-actions-1765706414732
Add Claude Code GitHub Workflow
2025-12-14 11:01:07 +01:00
Bas
a21fb04a6f "Claude Code Review workflow" 2025-12-14 11:00:16 +01:00
Bas
6ee1d7b5a9 "Claude PR Assistant workflow" 2025-12-14 11:00:15 +01:00
Claude
62a6cdcb99 Update package-lock.json 2025-12-14 09:57:07 +00:00
Claude
febe3b7270 Fix early ICE candidates lost due to late handler setup in createOffer()
Queue ICE candidates that are generated before we have the offerId from
the server. When the factory calls setLocalDescription(), ICE gathering
starts immediately, but we couldn't send candidates until we had the
offerId from publishService(). Now we:

1. Set up a queuing handler immediately after getting the pc from factory
2. Buffer any early candidates while publishing to get the offerId
3. Flush all queued candidates once we have the offerId
4. Continue handling future candidates normally

Fixes #2
2025-12-14 09:56:27 +00:00
83831cae77 v0.18.0 - Add WebRTC polyfill support for Node.js 2025-12-13 18:47:38 +01:00
e954a70aa7 Add WebRTC polyfill support for Node.js environments
Allow users to pass WebRTC polyfills (RTCPeerConnection, RTCIceCandidate) through RondevuOptions instead of manually setting global variables. The client now automatically applies these to globalThis when provided.

This simplifies Node.js integration:
- Before: Users had to manually set globalThis.RTCPeerConnection
- After: Pass rtcPeerConnection and rtcIceCandidate options

Example:
  const rondevu = await Rondevu.connect({
    apiUrl: 'https://api.example.com',
    username: 'alice',
    cryptoAdapter: new NodeCryptoAdapter(),
    rtcPeerConnection: wrtc.RTCPeerConnection,
    rtcIceCandidate: wrtc.RTCIceCandidate
  })
2025-12-13 18:47:27 +01:00
9 changed files with 428 additions and 93 deletions

View File

@@ -0,0 +1,57 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

50
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (rtcConfig) => {
const pc = new RTCPeerConnection(rtcConfig)
offerFactory: async (pc) => {
// pc is created by Rondevu with ICE handlers already attached
const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => {
@@ -64,7 +64,7 @@ await rondevu.publishService({
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
return { dc, offer }
}
})

34
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.17.1",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.17.1",
"version": "0.18.1",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.9.2"
"@noble/ed25519": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
@@ -1076,6 +1076,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz",
"integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -1310,15 +1320,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz",
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2838,6 +2839,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.17.1",
"version": "0.18.1",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",
@@ -25,6 +25,7 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",

View File

@@ -39,6 +39,7 @@ export interface Service {
export interface IceCandidate {
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
createdAt: number
}

View File

@@ -3,7 +3,7 @@
* WebRTC peer signaling client
*/
export { Rondevu } from './rondevu.js'
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js'
@@ -32,7 +32,11 @@ export type {
ConnectToServiceOptions,
ConnectionContext,
OfferContext,
OfferFactory
OfferFactory,
ActiveOffer,
FindServiceOptions,
ServiceResult,
PaginatedServiceResult
} from './rondevu.js'
export type { CryptoAdapter } from './crypto-adapter.js'

View File

@@ -81,13 +81,11 @@ export class NodeCryptoAdapter implements CryptoAdapter {
bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return Buffer.from(bytes).toString('base64')
}
base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return new Uint8Array(Buffer.from(base64, 'base64'))
}

View File

@@ -1,5 +1,6 @@
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'events'
// ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -57,15 +58,25 @@ export interface RondevuOptions {
batching?: BatcherOptions | false // Optional, defaults to enabled with default options
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
debug?: boolean // Optional: enable debug logging (default: false)
// WebRTC polyfills for Node.js environments (e.g., wrtc)
rtcPeerConnection?: typeof RTCPeerConnection
rtcIceCandidate?: typeof RTCIceCandidate
}
export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel
offer: RTCSessionDescriptionInit
}
export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>
/**
* Factory function for creating WebRTC offers.
* Rondevu creates the RTCPeerConnection and passes it to the factory,
* allowing ICE candidate handlers to be set up before setLocalDescription() is called.
*
* @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers)
* @returns Promise containing the data channel (optional) and offer SDP
*/
export type OfferFactory = (pc: RTCPeerConnection) => Promise<OfferContext>
export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
@@ -90,7 +101,7 @@ export interface ConnectToServiceOptions {
rtcConfig?: RTCConfiguration // Optional: override default ICE servers
}
interface ActiveOffer {
export interface ActiveOffer {
offerId: string
serviceFqn: string
pc: RTCPeerConnection
@@ -99,6 +110,73 @@ interface ActiveOffer {
createdAt: number
}
export interface FindServiceOptions {
mode?: 'direct' | 'random' | 'paginated' // Default: 'direct' if serviceFqn has username, 'random' otherwise
limit?: number // For paginated mode (default: 10)
offset?: number // For paginated mode (default: 0)
}
export interface ServiceResult {
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface PaginatedServiceResult {
services: ServiceResult[]
count: number
limit: number
offset: number
}
/**
* Base error class for Rondevu errors
*/
export class RondevuError extends Error {
constructor(message: string, public context?: Record<string, any>) {
super(message)
this.name = 'RondevuError'
Object.setPrototypeOf(this, RondevuError.prototype)
}
}
/**
* Network-related errors (API calls, connectivity)
*/
export class NetworkError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'NetworkError'
Object.setPrototypeOf(this, NetworkError.prototype)
}
}
/**
* Validation errors (invalid input, malformed data)
*/
export class ValidationError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ValidationError'
Object.setPrototypeOf(this, ValidationError.prototype)
}
}
/**
* WebRTC connection errors (peer connection failures, ICE issues)
*/
export class ConnectionError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ConnectionError'
Object.setPrototypeOf(this, ConnectionError.prototype)
}
}
/**
* Rondevu - Complete WebRTC signaling client
*
@@ -132,12 +210,12 @@ interface ActiveOffer {
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => {
* const pc = new RTCPeerConnection(rtcConfig)
* offerFactory: async (pc) => {
* // pc is created by Rondevu with ICE handlers already attached
* const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer()
* await pc.setLocalDescription(offer)
* return { pc, dc, offer }
* return { dc, offer }
* }
* })
*
@@ -153,7 +231,7 @@ interface ActiveOffer {
* rondevu.stopFilling()
* ```
*/
export class Rondevu {
export class Rondevu extends EventEmitter {
// Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
@@ -167,6 +245,8 @@ export class Rondevu {
private batchingOptions?: BatcherOptions | false
private iceServers: RTCIceServer[]
private debugEnabled: boolean
private rtcPeerConnection?: typeof RTCPeerConnection
private rtcIceCandidate?: typeof RTCIceCandidate
// Service management
private currentService: string | null = null
@@ -179,6 +259,7 @@ export class Rondevu {
private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
private isPolling = false // Guard against concurrent poll execution
private constructor(
apiUrl: string,
@@ -188,8 +269,11 @@ export class Rondevu {
iceServers: RTCIceServer[],
cryptoAdapter?: CryptoAdapter,
batchingOptions?: BatcherOptions | false,
debugEnabled = false
debugEnabled = false,
rtcPeerConnection?: typeof RTCPeerConnection,
rtcIceCandidate?: typeof RTCIceCandidate
) {
super()
this.apiUrl = apiUrl
this.username = username
this.keypair = keypair
@@ -198,6 +282,8 @@ export class Rondevu {
this.cryptoAdapter = cryptoAdapter
this.batchingOptions = batchingOptions
this.debugEnabled = debugEnabled
this.rtcPeerConnection = rtcPeerConnection
this.rtcIceCandidate = rtcIceCandidate
this.debug('Instance created:', {
username: this.username,
@@ -230,6 +316,14 @@ export class Rondevu {
static async connect(options: RondevuOptions): Promise<Rondevu> {
const username = options.username || Rondevu.generateAnonymousUsername()
// Apply WebRTC polyfills to global scope if provided (Node.js environments)
if (options.rtcPeerConnection) {
globalThis.RTCPeerConnection = options.rtcPeerConnection as any
}
if (options.rtcIceCandidate) {
globalThis.RTCIceCandidate = options.rtcIceCandidate as any
}
// Handle preset string or custom array
let iceServers: RTCIceServer[]
if (typeof options.iceServers === 'string') {
@@ -277,7 +371,9 @@ export class Rondevu {
iceServers,
options.cryptoAdapter,
options.batching,
options.debug || false
options.debug || false,
options.rtcPeerConnection,
options.rtcIceCandidate
)
}
@@ -318,15 +414,15 @@ export class Rondevu {
/**
* Default offer factory - creates a simple data channel connection
* The RTCPeerConnection is created by Rondevu and passed in
*/
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> {
const dc = pc.createDataChannel('default')
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
return { dc, offer }
}
/**
@@ -356,6 +452,10 @@ export class Rondevu {
/**
* Set up ICE candidate handler to send candidates to the server
*
* Note: This is used by connectToService() where the offerId is already known.
* For createOffer(), we use inline ICE handling with early candidate queuing
* since the offerId isn't available until after the factory completes.
*/
private setupIceCandidateHandler(
pc: RTCPeerConnection,
@@ -372,6 +472,9 @@ export class Rondevu {
? event.candidate.toJSON()
: event.candidate
// Emit local ICE candidate event
this.emit('ice:candidate:local', offerId, candidateData)
await this.api.addOfferIceCandidates(
serviceFqn,
offerId,
@@ -396,15 +499,63 @@ export class Rondevu {
iceServers: this.iceServers
}
this.debug('Creating new offer...')
// Create the offer using the factory
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}`
// Publish to server
this.debug('Creating new offer...')
// 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
const pc = new RTCPeerConnection(rtcConfig)
// 2. Set up ICE candidate handler with queuing BEFORE the factory runs
// This ensures we capture all candidates, even those generated immediately
// when setLocalDescription() is called in the factory
const earlyIceCandidates: RTCIceCandidateInit[] = []
let offerId: string | undefined
pc.onicecandidate = async (event) => {
if (event.candidate) {
// Handle both browser and Node.js (wrtc) environments
const candidateData = typeof event.candidate.toJSON === 'function'
? event.candidate.toJSON()
: event.candidate
// Emit local ICE candidate event
if (offerId) {
this.emit('ice:candidate:local', offerId, candidateData)
}
if (offerId) {
// We have the offerId, send directly
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData])
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
} else {
// Queue for later - we don't have the offerId yet
this.debug('Queuing early ICE candidate')
earlyIceCandidates.push(candidateData)
}
}
}
// 3. Call the factory with the pc - factory creates data channel and offer
// When factory calls setLocalDescription(), ICE gathering starts and
// candidates are captured by the handler we set up above
let dc: RTCDataChannel | undefined
let offer: RTCSessionDescriptionInit
try {
const factoryResult = await this.offerFactory(pc)
dc = factoryResult.dc
offer = factoryResult.offer
} catch (err) {
// Clean up the connection if factory fails
pc.close()
throw err
}
// 4. Publish to server to get offerId
const result = await this.api.publishService({
serviceFqn,
offers: [{ sdp: offer.sdp! }],
@@ -413,9 +564,9 @@ export class Rondevu {
message: '',
})
const offerId = result.offers[0].offerId
offerId = result.offers[0].offerId
// Store active offer
// 5. Store active offer
this.activeOffers.set(offerId, {
offerId,
serviceFqn,
@@ -426,16 +577,33 @@ export class Rondevu {
})
this.debug(`Offer created: ${offerId}`)
this.emit('offer:created', offerId, serviceFqn)
// Set up ICE candidate handler
this.setupIceCandidateHandler(pc, serviceFqn, offerId)
// Set up data channel open handler (offerer side)
if (dc) {
dc.onopen = () => {
this.debug(`Data channel opened for offer ${offerId}`)
this.emit('connection:opened', offerId, dc)
}
}
// Monitor connection state
// 6. Send any queued early ICE candidates
if (earlyIceCandidates.length > 0) {
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates)
} catch (err) {
console.error('[Rondevu] Failed to send early ICE candidates:', err)
}
}
// 7. Monitor connection state
pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.activeOffers.delete(offerId)
this.emit('connection:closed', offerId!)
this.activeOffers.delete(offerId!)
this.fillOffers() // Try to replace failed offer
}
}
@@ -467,6 +635,13 @@ export class Rondevu {
private async pollInternal(): Promise<void> {
if (!this.filling) return
// Prevent concurrent poll execution to avoid duplicate answer processing
if (this.isPolling) {
this.debug('Poll already in progress, skipping')
return
}
this.isPolling = true
try {
const result = await this.api.poll(this.lastPollTimestamp)
@@ -483,6 +658,7 @@ export class Rondevu {
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
this.emit('offer:answered', answer.offerId, answer.answererId)
// Create replacement offer
this.fillOffers()
@@ -497,6 +673,7 @@ export class Rondevu {
for (const item of answererCandidates) {
if (item.candidate) {
this.emit('ice:candidate:remote', offerId, item.candidate, item.role)
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
}
@@ -505,6 +682,8 @@ export class Rondevu {
}
} catch (err) {
console.error('[Rondevu] Polling error:', err)
} finally {
this.isPolling = false
}
}
@@ -541,6 +720,7 @@ export class Rondevu {
stopFilling(): void {
this.debug('Stopping offer filling and polling')
this.filling = false
this.isPolling = false // Reset polling guard
// Stop polling
if (this.pollingInterval) {
@@ -558,6 +738,51 @@ export class Rondevu {
this.activeOffers.clear()
}
/**
* Get the count of active offers
* @returns Number of active offers
*/
getOfferCount(): number {
return this.activeOffers.size
}
/**
* Check if an offer is currently connected
* @param offerId - The offer ID to check
* @returns True if the offer exists and has been answered
*/
isConnected(offerId: string): boolean {
const offer = this.activeOffers.get(offerId)
return offer ? offer.answered : false
}
/**
* Disconnect all active offers
* Similar to stopFilling() but doesn't stop the polling/filling process
*/
async disconnectAll(): Promise<void> {
this.debug('Disconnecting all offers')
for (const [offerId, offer] of this.activeOffers.entries()) {
this.debug(`Closing offer ${offerId}`)
offer.dc?.close()
offer.pc.close()
}
this.activeOffers.clear()
}
/**
* Get the current service status
* @returns Object with service state information
*/
getServiceStatus(): { active: boolean; offerCount: number; maxOffers: number; filling: boolean } {
return {
active: this.currentService !== null,
offerCount: this.activeOffers.size,
maxOffers: this.maxOffers,
filling: this.filling
}
}
/**
* Resolve the full service FQN from various input options
* Supports direct FQN, service+username, or service discovery
@@ -572,7 +797,7 @@ export class Rondevu {
} else if (service) {
// Discovery mode - get random service
this.debug(`Discovering service: ${service}`)
const discovered = await this.discoverService(service)
const discovered = await this.findService(service) as ServiceResult
return discovered.serviceFqn
} else {
throw new Error('Either serviceFqn or service must be provided')
@@ -599,6 +824,7 @@ export class Rondevu {
)
for (const item of result.candidates) {
if (item.candidate) {
this.emit('ice:candidate:remote', offerId, item.candidate, item.role)
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt
}
@@ -668,6 +894,7 @@ export class Rondevu {
pc.ondatachannel = (event) => {
this.debug('Data channel received from offerer')
dc = event.channel
this.emit('connection:opened', serviceData.offerId, dc)
resolve(dc)
}
})
@@ -739,56 +966,45 @@ export class Rondevu {
// ============================================
/**
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
* Find a service - unified discovery method
*
* Replaces getService(), discoverService(), and discoverServices() with a single method.
*
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
* @param options - Discovery options
*
* @example
* ```typescript
* // Direct lookup (has username)
* const service = await rondevu.findService('chat:1.0.0@alice')
*
* // Random discovery (no username)
* const service = await rondevu.findService('chat:1.0.0')
*
* // Paginated discovery
* const result = await rondevu.findService('chat:1.0.0', {
* mode: 'paginated',
* limit: 20,
* offset: 0
* })
* ```
*/
async getService(serviceFqn: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
async findService(
serviceFqn: string,
options?: FindServiceOptions
): Promise<ServiceResult | PaginatedServiceResult> {
const { mode, limit = 10, offset = 0 } = options || {}
/**
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async discoverService(serviceVersion: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceVersion)
}
// Auto-detect mode if not specified
const hasUsername = serviceFqn.includes('@')
const effectiveMode = mode || (hasUsername ? 'direct' : 'random')
/**
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{
services: Array<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}>
count: number
limit: number
offset: number
}> {
return await this.api.getService(serviceVersion, { limit, offset })
if (effectiveMode === 'paginated') {
return await this.api.getService(serviceFqn, { limit, offset })
} else {
// Both 'direct' and 'random' use the same API call
return await this.api.getService(serviceFqn)
}
}
// ============================================