Bas van den Aakster db8f0f4ced Fix answerer authorization for ICE candidates
The answerer was getting 403 Forbidden when sending ICE candidates because
the server didn't know who the answerer was yet. ICE gathering starts when
setLocalDescription is called, but we were calling /answer AFTER that.

Fixed by sending the answer to the server BEFORE setLocalDescription:
1. Create answer SDP
2. Send answer to server (registers answererPeerId)
3. Set up ICE handler
4. Set local description (ICE gathering starts)

This ensures the server has answererPeerId set before ICE candidates arrive,
so they're properly authorized.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:51:04 +01:00
2025-11-16 17:45:02 +01:00
2025-11-16 17:45:02 +01:00

@xtr-dev/rondevu-client

npm version

🌐 Topic-based peer discovery and WebRTC signaling client

TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling.

Related repositories:


Features

  • Topic-Based Discovery: Find peers by topics (e.g., torrent infohashes)
  • Stateless Authentication: No server-side sessions, portable credentials
  • Bloom Filters: Efficient peer exclusion for repeated discoveries
  • Multi-Offer Management: Create and manage multiple offers per peer
  • Complete WebRTC Signaling: Full offer/answer and ICE candidate exchange
  • TypeScript: Full type safety and autocomplete

Install

npm install @xtr-dev/rondevu-client

Quick Start

The easiest way to use Rondevu is with the high-level RondevuConnection class, which handles all WebRTC connection complexity including offer/answer exchange, ICE candidates, and connection lifecycle.

Creating an Offer (Peer A)

import { Rondevu } from '@xtr-dev/rondevu-client';

const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();

// Create a connection
const conn = client.createConnection();

// Set up event listeners
conn.on('connected', () => {
  console.log('Connected to peer!');
});

conn.on('datachannel', (channel) => {
  console.log('Data channel ready');

  channel.onmessage = (event) => {
    console.log('Received:', event.data);
  };

  channel.send('Hello from peer A!');
});

// Create offer and advertise on topics
const offerId = await conn.createOffer({
  topics: ['my-app', 'room-123'],
  ttl: 300000  // 5 minutes
});

console.log('Offer created:', offerId);
console.log('Share these topics with peers:', ['my-app', 'room-123']);

Answering an Offer (Peer B)

import { Rondevu } from '@xtr-dev/rondevu-client';

const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();

// Discover offers by topic
const offers = await client.offers.findByTopic('my-app', { limit: 10 });

if (offers.length > 0) {
  const offer = offers[0];

  // Create connection
  const conn = client.createConnection();

  // Set up event listeners
  conn.on('connecting', () => {
    console.log('Connecting...');
  });

  conn.on('connected', () => {
    console.log('Connected!');
  });

  conn.on('datachannel', (channel) => {
    console.log('Data channel ready');

    channel.onmessage = (event) => {
      console.log('Received:', event.data);
    };

    channel.send('Hello from peer B!');
  });

  // Answer the offer
  await conn.answer(offer.id, offer.sdp);
}

Connection Events

conn.on('connecting', () => {
  // Connection is being established
});

conn.on('connected', () => {
  // Connection established successfully
});

conn.on('disconnected', () => {
  // Connection lost or closed
});

conn.on('error', (error) => {
  // An error occurred
  console.error('Connection error:', error);
});

conn.on('datachannel', (channel) => {
  // Data channel is ready to use
});

conn.on('track', (event) => {
  // Media track received (for audio/video streaming)
  const stream = event.streams[0];
  videoElement.srcObject = stream;
});

Adding Media Tracks

// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});

// Add tracks to connection
stream.getTracks().forEach(track => {
  conn.addTrack(track, stream);
});

Connection Properties

// Get connection state
console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc.

// Get offer ID
console.log(conn.id);

// Get data channel
console.log(conn.channel);

Closing a Connection

conn.close();

Platform-Specific Setup

Node.js 18+ (with native fetch)

Works out of the box - no additional setup needed.

Node.js < 18 (without native fetch)

Install node-fetch and provide it to the client:

npm install node-fetch
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';

const client = new Rondevu({
  baseUrl: 'https://api.ronde.vu',
  fetch: fetch as any
});

Deno

import { Rondevu } from 'npm:@xtr-dev/rondevu-client';

const client = new Rondevu({
  baseUrl: 'https://api.ronde.vu'
});

Bun

Works out of the box - no additional setup needed.

Cloudflare Workers

import { Rondevu } from '@xtr-dev/rondevu-client';

export default {
  async fetch(request: Request, env: Env) {
    const client = new Rondevu({
      baseUrl: 'https://api.ronde.vu'
    });

    const creds = await client.register();
    return new Response(JSON.stringify(creds));
  }
};

Low-Level API Usage

For advanced use cases where you need direct control over the signaling process, you can use the low-level API:

import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';

const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });

// Register and get credentials
const creds = await client.register();
console.log('Peer ID:', creds.peerId);

// Save credentials for later use
localStorage.setItem('rondevu-creds', JSON.stringify(creds));

// Create offer with topics
const offers = await client.offers.create([{
  sdp: 'v=0...',  // Your WebRTC offer SDP
  topics: ['movie-xyz', 'hd-content'],
  ttl: 300000  // 5 minutes
}]);

// Discover peers by topic
const discovered = await client.offers.findByTopic('movie-xyz', {
  limit: 50
});

console.log(`Found ${discovered.length} peers`);

// Use bloom filter to exclude known peers
const knownPeers = new Set(['peer-id-1', 'peer-id-2']);
const bloom = new BloomFilter(1024, 3);
knownPeers.forEach(id => bloom.add(id));

const newPeers = await client.offers.findByTopic('movie-xyz', {
  bloomFilter: bloom.toBytes(),
  limit: 50
});

API Reference

Authentication

client.register()

Register a new peer and receive credentials.

const creds = await client.register();
// { peerId: '...', secret: '...' }

Topics

client.offers.getTopics(options?)

List all topics with active peer counts (paginated).

const result = await client.offers.getTopics({
  limit: 50,
  offset: 0
});

// {
//   topics: [
//     { topic: 'movie-xyz', activePeers: 42 },
//     { topic: 'torrent-abc', activePeers: 15 }
//   ],
//   total: 123,
//   limit: 50,
//   offset: 0
// }

Offers

client.offers.create(offers)

Create one or more offers with topics.

const offers = await client.offers.create([
  {
    sdp: 'v=0...',
    topics: ['topic-1', 'topic-2'],
    ttl: 300000  // optional, default 5 minutes
  }
]);

client.offers.findByTopic(topic, options?)

Find offers by topic with optional bloom filter.

const offers = await client.offers.findByTopic('movie-xyz', {
  limit: 50,
  bloomFilter: bloomBytes  // optional
});

client.offers.getMine()

Get all offers owned by the authenticated peer.

const myOffers = await client.offers.getMine();

client.offers.heartbeat(offerId)

Update last_seen timestamp for an offer.

await client.offers.heartbeat(offerId);

client.offers.delete(offerId)

Delete a specific offer.

await client.offers.delete(offerId);

client.offers.answer(offerId, sdp)

Answer an offer (locks it to answerer).

await client.offers.answer(offerId, answerSdp);

client.offers.getAnswers()

Poll for answers to your offers.

const answers = await client.offers.getAnswers();

ICE Candidates

client.offers.addIceCandidates(offerId, candidates)

Post ICE candidates for an offer.

await client.offers.addIceCandidates(offerId, [
  'candidate:1 1 UDP...'
]);

client.offers.getIceCandidates(offerId, since?)

Get ICE candidates from the other peer.

const candidates = await client.offers.getIceCandidates(offerId);

Bloom Filter

import { BloomFilter } from '@xtr-dev/rondevu-client';

// Create filter: size=1024 bits, hash=3 functions
const bloom = new BloomFilter(1024, 3);

// Add items
bloom.add('peer-id-1');
bloom.add('peer-id-2');

// Test membership
bloom.test('peer-id-1');  // true (probably)
bloom.test('unknown');    // false (definitely)

// Export for API
const bytes = bloom.toBytes();

TypeScript

All types are exported:

import type {
  Credentials,
  Offer,
  CreateOfferRequest,
  TopicInfo,
  IceCandidate,
  FetchFunction,
  RondevuOptions,
  ConnectionOptions,
  RondevuConnectionEvents
} from '@xtr-dev/rondevu-client';

Environment Compatibility

The client library is designed to work across different JavaScript runtimes:

Environment Native Fetch Custom Fetch Needed
Modern Browsers Yes No
Node.js 18+ Yes No
Node.js < 18 No Yes (node-fetch)
Deno Yes No
Bun Yes No
Cloudflare Workers Yes No

If your environment doesn't have native fetch:

npm install node-fetch
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';

const client = new Rondevu({
  baseUrl: 'https://rondevu.xtrdev.workers.dev',
  fetch: fetch as any
});

License

MIT

Description
No description provided
Readme 461 KiB
Languages
TypeScript 96.1%
JavaScript 3.9%