The createService storage method expects offers in the request, but publishService wasn't passing them. This caused undefined error when d1.ts tried to call request.offers.map(). Now correctly passes offers to createService which handles creating both the service and all offers atomically.
Rondevu Server
🌐 Simple WebRTC signaling with RPC interface
Scalable WebRTC signaling server with cryptographic username claiming, service publishing with semantic versioning, and efficient offer/answer exchange via JSON-RPC interface.
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
- RPC Interface: Single endpoint for all operations with batching support
- Username Claiming: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
- 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
- Batch Operations: Execute multiple operations in a single HTTP request
- 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
RPC Interface
All API calls are made to POST /rpc with JSON-RPC format.
Request Format
Single method call:
{
"method": "getUser",
"message": "getUser:alice:1733404800000",
"signature": "base64-encoded-signature",
"params": {
"username": "alice"
}
}
Batch calls:
[
{
"method": "getUser",
"message": "getUser:alice:1733404800000",
"signature": "base64-encoded-signature",
"params": { "username": "alice" }
},
{
"method": "claimUsername",
"message": "claim:bob:1733404800000",
"signature": "base64-encoded-signature",
"params": {
"username": "bob",
"publicKey": "base64-encoded-public-key"
}
}
]
Response Format
Single response:
{
"success": true,
"result": {
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "base64-encoded-public-key"
}
}
Batch responses:
[
{
"success": true,
"result": { "username": "alice", "available": false }
},
{
"success": true,
"result": { "success": true, "username": "bob" }
}
]
Error response:
{
"success": false,
"error": "Username already claimed by different public key"
}
RPC Methods
getUser
Check username availability
Parameters:
username- Username to check
Message format: getUser:{username}:{timestamp} (no authentication required)
Example:
{
"method": "getUser",
"message": "getUser:alice:1733404800000",
"signature": "base64-signature",
"params": { "username": "alice" }
}
Response:
{
"success": true,
"result": {
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "base64-encoded-public-key"
}
}
claimUsername
Claim a username with cryptographic proof
Parameters:
username- Username to claimpublicKey- Base64-encoded Ed25519 public key
Message format: claim:{username}:{timestamp}
Example:
{
"method": "claimUsername",
"message": "claim:alice:1733404800000",
"signature": "base64-signature",
"params": {
"username": "alice",
"publicKey": "base64-encoded-public-key"
}
}
Response:
{
"success": true,
"result": {
"success": true,
"username": "alice"
}
}
getService
Get service by FQN (direct lookup, random discovery, or paginated)
Parameters:
serviceFqn- Service FQN (e.g.,chat:1.0.0orchat:1.0.0@alice)limit- (optional) Number of results for paginated modeoffset- (optional) Offset for paginated mode
Message format: getService:{username}:{serviceFqn}:{timestamp}
Modes:
- Direct lookup (with @username): Returns specific user's service
- Random (without @username, no limit): Returns random service
- Paginated (without @username, with limit): Returns multiple services
Example:
{
"method": "getService",
"message": "getService:bob:chat:1.0.0:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice"
}
}
Response:
{
"success": true,
"result": {
"serviceId": "uuid",
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
}
publishService
Publish a service with offers
Parameters:
serviceFqn- Service FQN with username (e.g.,chat:1.0.0@alice)offers- Array of offers, each withsdpfieldttl- (optional) Time to live in milliseconds
Message format: publishService:{username}:{serviceFqn}:{timestamp}
Example:
{
"method": "publishService",
"message": "publishService:alice:chat:1.0.0@alice:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice",
"offers": [
{ "sdp": "v=0..." },
{ "sdp": "v=0..." }
],
"ttl": 300000
}
}
Response:
{
"success": true,
"result": {
"serviceId": "uuid",
"username": "alice",
"serviceFqn": "chat:1.0.0@alice",
"offers": [
{
"offerId": "offer-hash-1",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
}
deleteService
Delete a service
Parameters:
serviceFqn- Service FQN with username
Message format: deleteService:{username}:{serviceFqn}:{timestamp}
Example:
{
"method": "deleteService",
"message": "deleteService:alice:chat:1.0.0@alice:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice"
}
}
Response:
{
"success": true,
"result": { "success": true }
}
answerOffer
Answer a specific offer
Parameters:
serviceFqn- Service FQNofferId- Offer IDsdp- Answer SDP
Message format: answerOffer:{username}:{offerId}:{timestamp}
Example:
{
"method": "answerOffer",
"message": "answerOffer:bob:offer-hash:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"sdp": "v=0..."
}
}
Response:
{
"success": true,
"result": {
"success": true,
"offerId": "offer-hash"
}
}
getOfferAnswer
Get answer for an offer (offerer polls this)
Parameters:
serviceFqn- Service FQNofferId- Offer ID
Message format: getOfferAnswer:{username}:{offerId}:{timestamp}
Example:
{
"method": "getOfferAnswer",
"message": "getOfferAnswer:alice:offer-hash:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash"
}
}
Response:
{
"success": true,
"result": {
"sdp": "v=0...",
"offerId": "offer-hash",
"answererId": "bob",
"answeredAt": 1733404800000
}
}
poll
Combined polling for answers and ICE candidates
Parameters:
since- (optional) Timestamp to get only new data
Message format: poll:{username}:{timestamp}
Example:
{
"method": "poll",
"message": "poll:alice:1733404800000",
"signature": "base64-signature",
"params": {
"since": 1733404800000
}
}
Response:
{
"success": true,
"result": {
"answers": [
{
"offerId": "offer-hash",
"serviceId": "service-uuid",
"answererId": "bob",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
],
"iceCandidates": {
"offer-hash": [
{
"candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 },
"role": "answerer",
"username": "bob",
"createdAt": 1733404800000
}
]
}
}
}
addIceCandidates
Add ICE candidates to an offer
Parameters:
serviceFqn- Service FQNofferId- Offer IDcandidates- Array of ICE candidates
Message format: addIceCandidates:{username}:{offerId}:{timestamp}
Example:
{
"method": "addIceCandidates",
"message": "addIceCandidates:alice:offer-hash:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"candidates": [
{
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
]
}
}
Response:
{
"success": true,
"result": {
"count": 1,
"offerId": "offer-hash"
}
}
getIceCandidates
Get ICE candidates for an offer
Parameters:
serviceFqn- Service FQNofferId- Offer IDsince- (optional) Timestamp to get only new candidates
Message format: getIceCandidates:{username}:{offerId}:{timestamp}
Example:
{
"method": "getIceCandidates",
"message": "getIceCandidates:alice:offer-hash:1733404800000",
"signature": "base64-signature",
"params": {
"serviceFqn": "chat:1.0.0@alice",
"offerId": "offer-hash",
"since": 1733404800000
}
}
Response:
{
"success": true,
"result": {
"candidates": [
{
"candidate": {
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
},
"createdAt": 1733404800000
}
],
"offerId": "offer-hash"
}
}
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:
- message: Signed message with format-specific structure
- signature: Base64-encoded Ed25519 signature of the message
- Username is extracted from the message
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:
publishService:{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.4.x
See MIGRATION.md for detailed migration guide.
Key Changes:
- Moved from REST API to RPC interface with single
/rpcendpoint - All methods now use POST with JSON body
- Batch operations supported
- Authentication is per-method instead of per-endpoint middleware
License
MIT