- DELETE /services/:fqn uses request body for auth, not query parameters - Updated anonymous users description to reflect server capabilities (not client auto-claiming behavior which was removed)
Rondevu Server
🌐 Simple WebRTC signaling with username-based discovery
Scalable WebRTC signaling server with cryptographic username claiming, service publishing with semantic versioning, and efficient offer/answer exchange.
Related repositories:
- @xtr-dev/rondevu-client - TypeScript client library (npm)
- @xtr-dev/rondevu-server - HTTP signaling server (npm, live)
- @xtr-dev/rondevu-demo - Interactive demo (live)
Features
- Username Claiming: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
- Anonymous Users: Support for
anon-*usernames for quick testing - Service Publishing: Service:version@username naming (e.g.,
chat:1.0.0@alice) - Service Discovery: Random and paginated discovery for finding services without knowing usernames
- Semantic Versioning: Compatible version matching (chat:1.0.0 matches any 1.x.x)
- Signature-Based Authentication: All authenticated requests use Ed25519 signatures
- Complete WebRTC Signaling: Offer/answer exchange and ICE candidate relay
- Efficient Batch Polling: Combined endpoint for answers and ICE candidates (50% fewer HTTP requests)
- Dual Storage: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
Architecture
Username Claiming → Service Publishing → Service Discovery → WebRTC Connection
alice claims "alice" with Ed25519 signature
↓
alice publishes chat:1.0.0@alice with offers
↓
bob queries chat:1.0.0@alice (direct) or chat:1.0.0 (discovery) → gets offer SDP
↓
bob posts answer SDP → WebRTC connection established
↓
ICE candidates exchanged via server relay
Quick Start
Node.js:
npm install && npm start
Docker:
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu
Cloudflare Workers:
npx wrangler deploy
API Endpoints
Public Endpoints
GET /
Returns server version and info
Response:
{
"version": "0.4.0",
"name": "Rondevu",
"description": "DNS-like WebRTC signaling with username claiming and service discovery"
}
GET /health
Health check endpoint with version
Response:
{
"status": "ok",
"timestamp": 1733404800000,
"version": "0.4.0"
}
User Management
GET /users/:username
Check username availability and claim status
Response (Available):
{
"username": "alice",
"available": true
}
Response (Claimed):
{
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "base64-encoded-ed25519-public-key"
}
POST /users/:username
Claim a username with cryptographic proof
Request:
{
"publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000"
}
Response:
{
"success": true,
"username": "alice"
}
Validation:
- Username format:
^[a-z0-9][a-z0-9-]*[a-z0-9]$(3-32 characters) - Signature must be valid Ed25519 signature
- Timestamp must be within 5 minutes (replay protection)
- Expires after 365 days, auto-renewed on use
Service Management
POST /services
Publish a service with offers (requires username and signature)
Request:
{
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offers": [
{ "sdp": "v=0..." },
{ "sdp": "v=0..." }
],
"ttl": 300000,
"signature": "base64-encoded-signature",
"message": "publish:alice:chat:1.0.0@alice:1733404800000"
}
Response:
{
"serviceId": "uuid-v4",
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offers": [
{
"offerId": "offer-hash-1",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
Service FQN Format:
- Format:
service:version@username - Service name: Lowercase alphanumeric + dash (e.g.,
chat,video-call) - Version: Semantic versioning (e.g.,
1.0.0,2.1.3) - Username: Claimed username
- Example:
chat:1.0.0@alice
Validation:
- Service name pattern:
^[a-z0-9][a-z0-9-]*[a-z0-9]$ - Version pattern:
^[0-9]+\.[0-9]+\.[0-9]+$ - Must include @username
GET /services/:fqn
Get service by FQN - Three modes:
1. Direct Lookup (with username):
GET /services/chat:1.0.0@alice
Returns first available offer from Alice's chat:1.0.0 service.
2. Random Discovery (without username):
GET /services/chat:1.0.0
Returns a random available offer from any user's chat:1.0.0 service.
3. Paginated Discovery (with query params):
GET /services/chat:1.0.0?limit=10&offset=0
Returns array of unique available offers from different users.
Semver Matching:
- Requesting
chat:1.0.0matches any1.x.xversion - Major version must match exactly (
chat:1.0.0will NOT matchchat:2.0.0) - For major version 0, minor must also match (
0.1.0will NOT match0.2.0) - Returns the most recently published compatible version
Response (Single Offer):
{
"serviceId": "uuid",
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
Response (Paginated):
{
"services": [
{
"serviceId": "uuid",
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"count": 1,
"limit": 10,
"offset": 0
}
DELETE /services/:fqn
Unpublish a service (requires username, signature, and ownership)
Request:
{
"username": "alice",
"signature": "base64-encoded-signature",
"message": "deleteService:alice:chat:1.0.0@alice:1733404800000"
}
Response:
{
"success": true
}
WebRTC Signaling
POST /services/:fqn/offers/:offerId/answer
Post answer SDP to specific offer
Request:
{
"username": "bob",
"sdp": "v=0...",
"signature": "base64-encoded-signature",
"message": "answerOffer:{username}:{offerId}:{timestamp}"
}
Response:
{
"success": true,
"offerId": "offer-hash"
}
GET /services/:fqn/offers/:offerId/answer
Get answer SDP (offerer polls this)
Query Parameters:
username- Your usernamesignature- Base64-encoded Ed25519 signaturemessage- Signed message (format:getAnswer:{username}:{offerId}:{timestamp})
Response:
{
"sdp": "v=0...",
"offerId": "offer-hash",
"answererUsername": "bob",
"answeredAt": 1733404800000
}
Returns 404 if not yet answered.
GET /offers/answered
Get all answered offers (efficient batch polling for offerer)
Query Parameters:
username- Your usernamesignature- Base64-encoded Ed25519 signaturemessage- Signed message (format:getAnsweredOffers:{username}:{timestamp})since- Optional timestamp to get only new answers
Response:
{
"offers": [
{
"offerId": "offer-hash",
"serviceId": "service-uuid",
"answererUsername": "bob",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
]
}
GET /offers/poll
Combined polling for answers and ICE candidates (offerer)
Query Parameters:
username- Your usernamesignature- Base64-encoded Ed25519 signaturemessage- Signed message (format:pollOffers:{username}:{timestamp})since- Optional timestamp to get only new data
Response:
{
"answers": [
{
"offerId": "offer-hash",
"serviceId": "service-uuid",
"answererUsername": "bob",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
],
"iceCandidates": {
"offer-hash": [
{
"candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 },
"role": "answerer",
"username": "bob",
"createdAt": 1733404800000
}
]
}
}
More efficient than polling answers and ICE separately - reduces HTTP requests by 50%.
POST /services/:fqn/offers/:offerId/ice-candidates
Add ICE candidates to specific offer
Request:
{
"username": "alice",
"candidates": [
{
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
],
"signature": "base64-encoded-signature",
"message": "addIceCandidates:{username}:{offerId}:{timestamp}"
}
Response:
{
"count": 1,
"offerId": "offer-hash"
}
GET /services/:fqn/offers/:offerId/ice-candidates
Get ICE candidates for specific offer
Query Parameters:
username- Your usernamesignature- Base64-encoded Ed25519 signaturemessage- Signed message (format:getIceCandidates:{username}:{offerId}:{timestamp})since- Optional timestamp to get only new candidates
Response:
{
"candidates": [
{
"candidate": {
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
},
"createdAt": 1733404800000
}
],
"offerId": "offer-hash"
}
Note: Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
Configuration
Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port (Node.js/Docker) |
CORS_ORIGINS |
* |
Comma-separated allowed origins |
STORAGE_PATH |
./rondevu.db |
SQLite database path (use :memory: for in-memory) |
VERSION |
0.5.0 |
Server version (semver) |
OFFER_DEFAULT_TTL |
60000 |
Default offer TTL in ms (1 minute) |
OFFER_MIN_TTL |
60000 |
Minimum offer TTL in ms (1 minute) |
OFFER_MAX_TTL |
86400000 |
Maximum offer TTL in ms (24 hours) |
CLEANUP_INTERVAL |
60000 |
Cleanup interval in ms (1 minute) |
MAX_OFFERS_PER_REQUEST |
100 |
Maximum offers per create request |
Database Schema
usernames
username(PK): Claimed usernamepublic_key: Ed25519 public key (base64)claimed_at: Claim timestampexpires_at: Expiry timestamp (365 days)last_used: Last activity timestampmetadata: Optional JSON metadata
services
id(PK): Service ID (UUID)username(FK): Owner usernameservice_fqn: Fully qualified name (chat:1.0.0@alice)service_name: Service name component (chat)version: Version component (1.0.0)created_at,expires_at: Timestamps- UNIQUE constraint on (service_name, version, username)
offers
id(PK): Offer ID (hash of SDP)username(FK): Owner usernameservice_id(FK): Link to serviceservice_fqn: Denormalized service FQNsdp: WebRTC offer SDPanswerer_username: Username of answerer (null until answered)answer_sdp: WebRTC answer SDP (null until answered)answered_at: Timestamp when answeredcreated_at,expires_at,last_seen: Timestamps
ice_candidates
id(PK): Auto-increment IDoffer_id(FK): Link to offerusername: Username who sent the candidaterole: 'offerer' or 'answerer'candidate: JSON-encoded candidatecreated_at: Timestamp
Security
Ed25519 Signature Authentication
All authenticated requests require:
- username: Your claimed username
- signature: Base64-encoded Ed25519 signature of the message
- message: Signed message with format-specific structure
Username Claiming
- Algorithm: Ed25519 signatures
- Message Format:
claim:{username}:{timestamp} - Replay Protection: Timestamp must be within 5 minutes
- Key Management: Private keys never leave the client
- Validity: 365 days, auto-renewed on use
Anonymous Users
- Format:
anon-{timestamp}-{random}(e.g.,anon-lx2w34-a3f501) - Generation: Can be generated by client for testing
- Behavior: Same as regular usernames, must be explicitly claimed like any username
Service Publishing
- Ownership Verification: Every publish requires username signature
- Message Format:
publish:{username}:{serviceFqn}:{timestamp} - Auto-Renewal: Publishing a service extends username expiry
ICE Candidate Filtering
- Server filters candidates by role to prevent peers from receiving their own candidates
- Offerers receive only answerer candidates
- Answerers receive only offerer candidates
Migration from v0.3.x
See MIGRATION.md for detailed migration guide.
Key Changes:
- Service FQN format changed from
service@versiontoservice:version@username - Removed UUID privacy layer - direct FQN-based access
- Removed public/private service distinction
- Added service discovery (random and paginated)
- Added combined polling endpoint (/offers/poll)
- ICE candidate endpoints moved to offer-specific routes
License
MIT