Bas van den Aakster caae10bcac Fix: Pass offers to createService method
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.
2025-12-12 21:09:15 +01:00

Rondevu Server

npm version

🌐 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:


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 claim
  • publicKey - 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.0 or chat:1.0.0@alice)
  • limit - (optional) Number of results for paginated mode
  • offset - (optional) Offset for paginated mode

Message format: getService:{username}:{serviceFqn}:{timestamp}

Modes:

  1. Direct lookup (with @username): Returns specific user's service
  2. Random (without @username, no limit): Returns random service
  3. 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 with sdp field
  • ttl - (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 FQN
  • offerId - Offer ID
  • sdp - 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 FQN
  • offerId - 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 FQN
  • offerId - Offer ID
  • candidates - 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 FQN
  • offerId - Offer ID
  • since - (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 username
  • public_key: Ed25519 public key (base64)
  • claimed_at: Claim timestamp
  • expires_at: Expiry timestamp (365 days)
  • last_used: Last activity timestamp
  • metadata: Optional JSON metadata

services

  • id (PK): Service ID (UUID)
  • username (FK): Owner username
  • service_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 username
  • service_id (FK): Link to service
  • service_fqn: Denormalized service FQN
  • sdp: WebRTC offer SDP
  • answerer_username: Username of answerer (null until answered)
  • answer_sdp: WebRTC answer SDP (null until answered)
  • answered_at: Timestamp when answered
  • created_at, expires_at, last_seen: Timestamps

ice_candidates

  • id (PK): Auto-increment ID
  • offer_id (FK): Link to offer
  • username: Username who sent the candidate
  • role: 'offerer' or 'answerer'
  • candidate: JSON-encoded candidate
  • created_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 /rpc endpoint
  • All methods now use POST with JSON body
  • Batch operations supported
  • Authentication is per-method instead of per-endpoint middleware

License

MIT

Description
No description provided
Readme 642 KiB
Languages
TypeScript 97.9%
Dockerfile 1.3%
JavaScript 0.8%