mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-08 00:33:24 +00:00
feat: Implement content-based offer IDs with SHA-256 hashing
- Added hash-id.ts utility for SHA-256 content hashing
- Offer IDs now generated from hash of {sdp, topics} (sorted)
- Removed peerId from hash (inferred from authentication)
- Server generates deterministic IDs for idempotent offer creation
- Updated SQLite and D1 storage implementations
- Removed optional id field from CreateOfferRequest
- Same offer content always produces same ID
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
README.md
|
||||
API.md
|
||||
.DS_Store
|
||||
*.db
|
||||
*.db-journal
|
||||
data/
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
data/
|
||||
|
||||
# Wrangler / Cloudflare Workers
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
wrangler.toml.backup
|
||||
458
API.md
Normal file
458
API.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# HTTP API
|
||||
|
||||
This API provides peer signaling and tracking endpoints for distributed peer-to-peer applications. Uses JSON request/response bodies with Origin-based session isolation.
|
||||
|
||||
All endpoints require an `Origin` header and accept `application/json` content type.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Sessions are organized by:
|
||||
- **Origin**: The HTTP Origin header (e.g., `https://example.com`) - isolates sessions by application
|
||||
- **Topic**: A string identifier for grouping related peers (max 256 chars)
|
||||
- **Info**: User-provided metadata (max 1024 chars) to uniquely identify each peer
|
||||
|
||||
This allows multiple peers from the same application (origin) to discover each other through topics while preventing duplicate connections by comparing the info field.
|
||||
|
||||
---
|
||||
|
||||
## GET `/`
|
||||
|
||||
Returns server version information including the git commit hash used to build the server.
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"version": "a1b2c3d"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Returns the git commit hash from build time
|
||||
- Returns "unknown" if git information is not available
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:3000/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET `/topics`
|
||||
|
||||
Lists all topics with the count of available peers for each (paginated). Returns only topics that have unanswered sessions.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers:**
|
||||
- `Origin: https://example.com` (required)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|--------|----------|---------|---------------------------------|
|
||||
| `page` | number | No | `1` | Page number (starting from 1) |
|
||||
| `limit` | number | No | `100` | Results per page (max 1000) |
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"topics": [
|
||||
{
|
||||
"topic": "my-room",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"topic": "another-room",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 100,
|
||||
"total": 2,
|
||||
"hasMore": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Only returns topics from the same origin as the request
|
||||
- Only includes topics with at least one unanswered session
|
||||
- Topics are sorted alphabetically
|
||||
- Counts only include unexpired sessions
|
||||
- Maximum 1000 results per page
|
||||
|
||||
### Examples
|
||||
|
||||
**Default pagination (page 1, limit 100):**
|
||||
```bash
|
||||
curl -X GET http://localhost:3000/topics \
|
||||
-H "Origin: https://example.com"
|
||||
```
|
||||
|
||||
**Custom pagination:**
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/topics?page=2&limit=50" \
|
||||
-H "Origin: https://example.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET `/:topic/sessions`
|
||||
|
||||
Discovers available peers for a given topic. Returns all unanswered sessions from the requesting origin.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers:**
|
||||
- `Origin: https://example.com` (required)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------------------------|
|
||||
| `topic` | string | Yes | Topic identifier to query |
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"info": "peer-123",
|
||||
"offer": "<SIGNALING_DATA>",
|
||||
"offerCandidates": ["<SIGNALING_DATA>"],
|
||||
"createdAt": 1699564800000,
|
||||
"expiresAt": 1699565100000
|
||||
},
|
||||
{
|
||||
"code": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"info": "peer-456",
|
||||
"offer": "<SIGNALING_DATA>",
|
||||
"offerCandidates": [],
|
||||
"createdAt": 1699564850000,
|
||||
"expiresAt": 1699565150000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Only returns sessions from the same origin as the request
|
||||
- Only returns sessions that haven't been answered yet
|
||||
- Sessions are ordered by creation time (newest first)
|
||||
- Use the `info` field to avoid answering your own offers
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:3000/my-room/sessions \
|
||||
-H "Origin: https://example.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST `/:topic/offer`
|
||||
|
||||
Announces peer availability and creates a new session for the specified topic. Returns a unique session code (UUID) for other peers to connect to.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type: application/json`
|
||||
- `Origin: https://example.com` (required)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------------------------------|
|
||||
| `topic` | string | Yes | Topic identifier for grouping peers (max 256 characters) |
|
||||
|
||||
**Body Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------------------------------|
|
||||
| `info` | string | Yes | Peer identifier/metadata (max 1024 characters) |
|
||||
| `offer` | string | Yes | Signaling data for peer connection |
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
Returns a unique UUID session code.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/my-room/offer \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{
|
||||
"info": "peer-123",
|
||||
"offer": "<SIGNALING_DATA>"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {"code":"550e8400-e29b-41d4-a716-446655440000"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST `/answer`
|
||||
|
||||
Connects to an existing peer session by sending connection data or exchanging signaling information.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type: application/json`
|
||||
- `Origin: https://example.com` (required)
|
||||
|
||||
**Body Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-------------|--------|----------|----------------------------------------------------------|
|
||||
| `code` | string | Yes | The session UUID from the offer |
|
||||
| `answer` | string | No* | Response signaling data for connection establishment |
|
||||
| `candidate` | string | No* | Additional signaling data for connection negotiation |
|
||||
| `side` | string | Yes | Which peer is sending: `offerer` or `answerer` |
|
||||
|
||||
*Either `answer` or `candidate` must be provided, but not both.
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Origin header must match the session's origin
|
||||
- Sessions are isolated by origin to group topics by domain
|
||||
|
||||
### Examples
|
||||
|
||||
**Sending connection response:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/answer \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"answer": "<SIGNALING_DATA>",
|
||||
"side": "answerer"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {"success":true}
|
||||
```
|
||||
|
||||
**Sending additional signaling data:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/answer \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"candidate": "<SIGNALING_DATA>",
|
||||
"side": "offerer"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {"success":true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST `/poll`
|
||||
|
||||
Retrieves session data including offers, responses, and signaling information from the other peer.
|
||||
|
||||
### Request
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type: application/json`
|
||||
- `Origin: https://example.com` (required)
|
||||
|
||||
**Body Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------------------------------------------|
|
||||
| `code` | string | Yes | The session UUID |
|
||||
| `side` | string | Yes | Which side is polling: `offerer` or `answerer` |
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
|
||||
Response varies by side:
|
||||
|
||||
**For `side=offerer` (the offerer polls for response from answerer):**
|
||||
```json
|
||||
{
|
||||
"answer": "<SIGNALING_DATA>",
|
||||
"answerCandidates": [
|
||||
"<SIGNALING_DATA_1>",
|
||||
"<SIGNALING_DATA_2>"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**For `side=answerer` (the answerer polls for offer from offerer):**
|
||||
```json
|
||||
{
|
||||
"offer": "<SIGNALING_DATA>",
|
||||
"offerCandidates": [
|
||||
"<SIGNALING_DATA_1>",
|
||||
"<SIGNALING_DATA_2>"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `answer` will be `null` if the answerer hasn't responded yet
|
||||
- Candidate arrays will be empty `[]` if no additional signaling data has been sent
|
||||
- Use this endpoint for polling to check for new signaling data
|
||||
- Origin header must match the session's origin
|
||||
|
||||
### Examples
|
||||
|
||||
**Answerer polling for signaling data:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/poll \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"side": "answerer"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "offer": "<SIGNALING_DATA>",
|
||||
# "offerCandidates": ["<SIGNALING_DATA>"]
|
||||
# }
|
||||
```
|
||||
|
||||
**Offerer polling for response:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/poll \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://example.com" \
|
||||
-d '{
|
||||
"code": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"side": "offerer"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "answer": "<SIGNALING_DATA>",
|
||||
# "answerCandidates": ["<SIGNALING_DATA>"]
|
||||
# }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET `/health`
|
||||
|
||||
Health check endpoint.
|
||||
|
||||
### Response
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Success (200 OK):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": 1699564800000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return the following error responses:
|
||||
|
||||
**400 Bad Request:**
|
||||
```json
|
||||
{
|
||||
"error": "Missing or invalid required parameter: topic"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found:**
|
||||
```json
|
||||
{
|
||||
"error": "Session not found, expired, or origin mismatch"
|
||||
}
|
||||
```
|
||||
|
||||
**500 Internal Server Error:**
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Flow
|
||||
|
||||
### Peer Discovery and Connection
|
||||
|
||||
1. **Check server version (optional):**
|
||||
- GET `/` to see server version information
|
||||
|
||||
2. **Discover active topics:**
|
||||
- GET `/topics` to see all topics and peer counts
|
||||
- Optional: paginate through results with `?page=2&limit=100`
|
||||
|
||||
3. **Peer A announces availability:**
|
||||
- POST `/:topic/offer` with peer identifier and signaling data
|
||||
- Receives a unique session code
|
||||
|
||||
4. **Peer B discovers peers:**
|
||||
- GET `/:topic/sessions` to list available sessions in a topic
|
||||
- Filters out sessions with their own info to avoid self-connection
|
||||
- Selects a peer to connect to
|
||||
|
||||
5. **Peer B initiates connection:**
|
||||
- POST `/answer` with the session code and their signaling data
|
||||
|
||||
6. **Both peers exchange signaling information:**
|
||||
- POST `/answer` with additional signaling data as needed
|
||||
- POST `/poll` to retrieve signaling data from the other peer
|
||||
|
||||
7. **Peer connection established**
|
||||
- Peers use exchanged signaling data to establish direct connection
|
||||
- Session automatically expires after configured timeout
|
||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Rondevu Server Development Guidelines
|
||||
|
||||
## WebRTC Signaling Best Practices
|
||||
|
||||
### ICE Candidate Storage
|
||||
|
||||
**IMPORTANT: Store ICE candidates as raw JSON without enforcing structure.**
|
||||
|
||||
When handling ICE candidates in the signaling server:
|
||||
|
||||
- ✅ **DO** store candidates as `JSON.stringify(candidate)` in the database
|
||||
- ✅ **DO** retrieve candidates as `JSON.parse(candidate)` from the database
|
||||
- ✅ **DO** use generic types like `any` in TypeScript for candidate data
|
||||
- ❌ **DON'T** define strict types for ICE candidate structure
|
||||
- ❌ **DON'T** validate or modify candidate properties
|
||||
- ❌ **DON'T** assume you know what properties clients will send
|
||||
|
||||
**Why?** The server is just a relay - it doesn't need to understand the candidate structure. Different browsers and future WebRTC versions may include different properties. By keeping the server agnostic, we maintain maximum compatibility.
|
||||
|
||||
### Server Role Filtering
|
||||
|
||||
The server MUST filter ICE candidates by role:
|
||||
- Offerers receive only answerer candidates (`WHERE role = 'answerer'`)
|
||||
- Answerers receive only offerer candidates (`WHERE role = 'offerer'`)
|
||||
|
||||
This prevents peers from receiving their own candidates, which would cause connection failures.
|
||||
|
||||
## Security
|
||||
|
||||
- Always validate authentication tokens before allowing operations
|
||||
- Verify ownership before allowing modifications
|
||||
- Rate limit API endpoints to prevent abuse
|
||||
- Clean up expired offers regularly
|
||||
|
||||
## Performance
|
||||
|
||||
- Use transactions for batch operations (SQLite)
|
||||
- Index frequently queried columns (offer_id, role, created_at)
|
||||
- Set appropriate TTLs for offers
|
||||
- Implement pagination for large result sets
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Handle errors gracefully with informative HTTP status codes
|
||||
- Log important events for debugging
|
||||
- Use TypeScript types for API contracts, but keep data types generic
|
||||
- Write tests for critical paths
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY tsconfig.json ./
|
||||
COPY build.js ./
|
||||
COPY src ./src
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV PORT=3000
|
||||
ENV STORAGE_TYPE=sqlite
|
||||
ENV STORAGE_PATH=/app/data/sessions.db
|
||||
ENV SESSION_TIMEOUT=300000
|
||||
ENV CODE_CHARS=0123456789
|
||||
ENV CODE_LENGTH=9
|
||||
ENV CORS_ORIGINS=*
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:${PORT}/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||
|
||||
# Start server
|
||||
CMD ["node", "dist/index.js"]
|
||||
183
README.md
Normal file
183
README.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Rondevu Server
|
||||
|
||||
🌐 **Topic-based peer discovery and WebRTC signaling**
|
||||
|
||||
Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling.
|
||||
|
||||
**Related repositories:**
|
||||
- [@xtr-dev/rondevu-client](https://www.npmjs.com/package/@xtr-dev/rondevu-client) - TypeScript client library
|
||||
- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Topic-Based Discovery**: Tag offers with topics (e.g., torrent infohashes) for efficient peer finding
|
||||
- **Stateless Authentication**: AES-256-GCM encrypted credentials, no server-side sessions
|
||||
- **Bloom Filters**: Client-side peer exclusion for efficient discovery
|
||||
- **Multi-Offer Support**: Create multiple offers per peer simultaneously
|
||||
- **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay
|
||||
- **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Node.js:**
|
||||
```bash
|
||||
npm install && npm start
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu
|
||||
```
|
||||
|
||||
**Cloudflare Workers:**
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
#### `GET /`
|
||||
Returns server version and info
|
||||
|
||||
#### `GET /health`
|
||||
Health check endpoint with version
|
||||
|
||||
#### `POST /register`
|
||||
Register a new peer and receive credentials (peerId + secret)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"peerId": "f17c195f067255e357232e34cf0735d9",
|
||||
"secret": "DdorTR8QgSn9yngn+4qqR8cs1aMijvX..."
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /topics?limit=50&offset=0`
|
||||
List all topics with active peer counts (paginated)
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Maximum number of topics to return (default: 50, max: 200)
|
||||
- `offset` (optional): Number of topics to skip (default: 0)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"topics": [
|
||||
{"topic": "movie-xyz", "activePeers": 42},
|
||||
{"topic": "torrent-abc", "activePeers": 15}
|
||||
],
|
||||
"total": 123,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/by-topic/:topic?limit=50&bloom=...`
|
||||
Find offers by topic with optional bloom filter exclusion
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Maximum offers to return (default: 50, max: 200)
|
||||
- `bloom` (optional): Base64-encoded bloom filter to exclude known peers
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"topic": "movie-xyz",
|
||||
"offers": [
|
||||
{
|
||||
"id": "offer-id",
|
||||
"peerId": "peer-id",
|
||||
"sdp": "v=0...",
|
||||
"topics": ["movie-xyz", "hd-content"],
|
||||
"expiresAt": 1234567890,
|
||||
"lastSeen": 1234567890
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"returned": 10
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /peers/:peerId/offers`
|
||||
View all offers from a specific peer
|
||||
|
||||
### Authenticated Endpoints
|
||||
|
||||
All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header.
|
||||
|
||||
#### `POST /offers`
|
||||
Create one or more offers
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"offers": [
|
||||
{
|
||||
"sdp": "v=0...",
|
||||
"topics": ["movie-xyz", "hd-content"],
|
||||
"ttl": 300000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/mine`
|
||||
List all offers owned by authenticated peer
|
||||
|
||||
#### `PUT /offers/:offerId/heartbeat`
|
||||
Update last_seen timestamp for an offer
|
||||
|
||||
#### `DELETE /offers/:offerId`
|
||||
Delete a specific offer
|
||||
|
||||
#### `POST /offers/:offerId/answer`
|
||||
Answer an offer (locks it to answerer)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"sdp": "v=0..."
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/answers`
|
||||
Poll for answers to your offers
|
||||
|
||||
#### `POST /offers/:offerId/ice-candidates`
|
||||
Post ICE candidates for an offer
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"candidates": ["candidate:1 1 UDP..."]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/:offerId/ice-candidates?since=1234567890`
|
||||
Get ICE candidates from the other peer
|
||||
|
||||
## 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.4.0` | Server version (semver) |
|
||||
| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption |
|
||||
| `OFFER_DEFAULT_TTL` | `300000` | Default offer TTL in ms (5 minutes) |
|
||||
| `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) |
|
||||
| `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) |
|
||||
| `MAX_OFFERS_PER_REQUEST` | `10` | Maximum offers per create request |
|
||||
| `MAX_TOPICS_PER_OFFER` | `20` | Maximum topics per offer |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
29
build.js
Normal file
29
build.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Build script using esbuild
|
||||
const esbuild = require('esbuild');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Get git commit hash
|
||||
let version = 'unknown';
|
||||
try {
|
||||
version = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
} catch (err) {
|
||||
console.warn('Could not get git commit hash, using "unknown"');
|
||||
}
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: 'dist/index.js',
|
||||
format: 'cjs',
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'@hono/node-server',
|
||||
'hono'
|
||||
],
|
||||
sourcemap: true,
|
||||
define: {
|
||||
'process.env.RONDEVU_VERSION': JSON.stringify(version)
|
||||
}
|
||||
}).catch(() => process.exit(1));
|
||||
21
migrations/0001_add_peer_id.sql
Normal file
21
migrations/0001_add_peer_id.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Drop old sessions table with 'info' column
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
|
||||
-- Create sessions table with peer_id column
|
||||
CREATE TABLE sessions (
|
||||
code TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
|
||||
offer TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX idx_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX idx_origin_topic ON sessions(origin, topic);
|
||||
CREATE INDEX idx_origin_topic_expires ON sessions(origin, topic, expires_at);
|
||||
22
migrations/0002_remove_topics.sql
Normal file
22
migrations/0002_remove_topics.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Remove topics and rename sessions to offers
|
||||
-- This is a breaking change requiring a fresh database
|
||||
|
||||
-- Drop old sessions table
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
|
||||
-- Create offers table (without topic)
|
||||
CREATE TABLE offers (
|
||||
code TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
|
||||
offer TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX idx_offers_expires_at ON offers(expires_at);
|
||||
CREATE INDEX idx_offers_origin ON offers(origin);
|
||||
29
migrations/0003_remove_origin.sql
Normal file
29
migrations/0003_remove_origin.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Migration: Remove origin column from offers table
|
||||
-- This simplifies offer lookup to only use offer codes
|
||||
-- Origin-based bucketing is no longer needed
|
||||
|
||||
-- Create new offers table without origin column
|
||||
CREATE TABLE offers_new (
|
||||
code TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
|
||||
offer TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Copy data from old table
|
||||
INSERT INTO offers_new (code, peer_id, offer, answer, offer_candidates, answer_candidates, created_at, expires_at)
|
||||
SELECT code, peer_id, offer, answer, offer_candidates, answer_candidates, created_at, expires_at
|
||||
FROM offers;
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE offers;
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE offers_new RENAME TO offers;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires_at ON offers(expires_at);
|
||||
18
migrations/schema.sql
Normal file
18
migrations/schema.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Create sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
code TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024),
|
||||
offer TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
offer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
answer_candidates TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
|
||||
1216
package-lock.json
generated
Normal file
1216
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@xtr-dev/rondevu-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Topic-based peer discovery and signaling server for distributed P2P applications",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"typecheck": "tsc",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"deploy": "npx wrangler deploy src/worker.ts --var VERSION:$(git rev-parse --short HEAD)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.9.2",
|
||||
"esbuild": "^0.25.11",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"hono": "^4.10.4"
|
||||
}
|
||||
}
|
||||
529
src/app.ts
Normal file
529
src/app.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Storage } from './storage/types.ts';
|
||||
import { Config } from './config.ts';
|
||||
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
|
||||
import { generatePeerId, encryptPeerId } from './crypto.ts';
|
||||
import { parseBloomFilter } from './bloom.ts';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
/**
|
||||
* Creates the Hono application with topic-based WebRTC signaling endpoints
|
||||
*/
|
||||
export function createApp(storage: Storage, config: Config) {
|
||||
const app = new Hono();
|
||||
|
||||
// Create auth middleware
|
||||
const authMiddleware = createAuthMiddleware(config.authSecret);
|
||||
|
||||
// Enable CORS with dynamic origin handling
|
||||
app.use('/*', cors({
|
||||
origin: (origin) => {
|
||||
// If no origin restrictions (wildcard), allow any origin
|
||||
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
|
||||
return origin;
|
||||
}
|
||||
// Otherwise check if origin is in allowed list
|
||||
if (config.corsOrigins.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
// Default to first allowed origin
|
||||
return config.corsOrigins[0];
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
|
||||
exposeHeaders: ['Content-Type'],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Returns server version information
|
||||
*/
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
version: config.version,
|
||||
name: 'Rondevu',
|
||||
description: 'Topic-based peer discovery and signaling server'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint with version
|
||||
*/
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
version: config.version
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /register
|
||||
* Register a new peer and receive credentials
|
||||
*/
|
||||
app.post('/register', async (c) => {
|
||||
try {
|
||||
// Generate new peer ID
|
||||
const peerId = generatePeerId();
|
||||
|
||||
// Encrypt peer ID with server secret (async operation)
|
||||
const secret = await encryptPeerId(peerId, config.authSecret);
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
secret
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error registering peer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers
|
||||
* Creates one or more offers with topics
|
||||
* Requires authentication
|
||||
*/
|
||||
app.post('/offers', authMiddleware, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { offers } = body;
|
||||
|
||||
if (!Array.isArray(offers) || offers.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
||||
}
|
||||
|
||||
if (offers.length > config.maxOffersPerRequest) {
|
||||
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
|
||||
}
|
||||
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Validate and prepare offers
|
||||
const offerRequests = [];
|
||||
for (const offer of offers) {
|
||||
// Validate SDP
|
||||
if (!offer.sdp || typeof offer.sdp !== 'string') {
|
||||
return c.json({ error: 'Each offer must have an sdp field' }, 400);
|
||||
}
|
||||
|
||||
if (offer.sdp.length > 65536) {
|
||||
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
||||
}
|
||||
|
||||
// Validate topics
|
||||
if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
|
||||
return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
|
||||
}
|
||||
|
||||
if (offer.topics.length > config.maxTopicsPerOffer) {
|
||||
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
|
||||
}
|
||||
|
||||
for (const topic of offer.topics) {
|
||||
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
|
||||
return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clamp TTL
|
||||
let ttl = offer.ttl || config.offerDefaultTtl;
|
||||
if (ttl < config.offerMinTtl) {
|
||||
ttl = config.offerMinTtl;
|
||||
}
|
||||
if (ttl > config.offerMaxTtl) {
|
||||
ttl = config.offerMaxTtl;
|
||||
}
|
||||
|
||||
offerRequests.push({
|
||||
id: offer.id,
|
||||
peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// Create offers
|
||||
const createdOffers = await storage.createOffers(offerRequests);
|
||||
|
||||
// Return simplified response
|
||||
return c.json({
|
||||
offers: createdOffers.map(o => ({
|
||||
id: o.id,
|
||||
peerId: o.peerId,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error creating offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/by-topic/:topic
|
||||
* Find offers by topic with optional bloom filter exclusion
|
||||
* Public endpoint (no auth required)
|
||||
*/
|
||||
app.get('/offers/by-topic/:topic', async (c) => {
|
||||
try {
|
||||
const topic = c.req.param('topic');
|
||||
const bloomParam = c.req.query('bloom');
|
||||
const limitParam = c.req.query('limit');
|
||||
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
||||
|
||||
// Parse bloom filter if provided
|
||||
let excludePeerIds: string[] = [];
|
||||
if (bloomParam) {
|
||||
const bloom = parseBloomFilter(bloomParam);
|
||||
if (!bloom) {
|
||||
return c.json({ error: 'Invalid bloom filter format' }, 400);
|
||||
}
|
||||
|
||||
// Get all offers for topic first
|
||||
const allOffers = await storage.getOffersByTopic(topic);
|
||||
|
||||
// Test each peer ID against bloom filter
|
||||
const excludeSet = new Set<string>();
|
||||
for (const offer of allOffers) {
|
||||
if (bloom.test(offer.peerId)) {
|
||||
excludeSet.add(offer.peerId);
|
||||
}
|
||||
}
|
||||
|
||||
excludePeerIds = Array.from(excludeSet);
|
||||
}
|
||||
|
||||
// Get filtered offers
|
||||
let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
|
||||
|
||||
// Apply limit
|
||||
const total = offers.length;
|
||||
offers = offers.slice(0, limit);
|
||||
|
||||
return c.json({
|
||||
topic,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
peerId: o.peerId,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen
|
||||
})),
|
||||
total: bloomParam ? total + excludePeerIds.length : total,
|
||||
returned: offers.length
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching offers by topic:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /topics
|
||||
* List all topics with active peer counts (paginated)
|
||||
* Public endpoint (no auth required)
|
||||
*/
|
||||
app.get('/topics', async (c) => {
|
||||
try {
|
||||
const limitParam = c.req.query('limit');
|
||||
const offsetParam = c.req.query('offset');
|
||||
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
||||
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
||||
|
||||
const result = await storage.getTopics(limit, offset);
|
||||
|
||||
return c.json({
|
||||
topics: result.topics,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching topics:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /peers/:peerId/offers
|
||||
* View all offers from a specific peer
|
||||
* Public endpoint
|
||||
*/
|
||||
app.get('/peers/:peerId/offers', async (c) => {
|
||||
try {
|
||||
const peerId = c.req.param('peerId');
|
||||
const offers = await storage.getOffersByPeerId(peerId);
|
||||
|
||||
// Collect unique topics
|
||||
const topicsSet = new Set<string>();
|
||||
offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen
|
||||
})),
|
||||
topics: Array.from(topicsSet)
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching peer offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/mine
|
||||
* List all offers owned by authenticated peer
|
||||
* Requires authentication
|
||||
*/
|
||||
app.get('/offers/mine', authMiddleware, async (c) => {
|
||||
try {
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const offers = await storage.getOffersByPeerId(peerId);
|
||||
|
||||
return c.json({
|
||||
peerId,
|
||||
offers: offers.map(o => ({
|
||||
id: o.id,
|
||||
sdp: o.sdp,
|
||||
topics: o.topics,
|
||||
createdAt: o.createdAt,
|
||||
expiresAt: o.expiresAt,
|
||||
lastSeen: o.lastSeen,
|
||||
answererPeerId: o.answererPeerId,
|
||||
answeredAt: o.answeredAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching own offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /offers/:offerId/heartbeat
|
||||
* Update last_seen timestamp for an offer
|
||||
* Requires authentication and ownership
|
||||
*/
|
||||
app.put('/offers/:offerId/heartbeat', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Verify ownership
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
if (offer.peerId !== peerId) {
|
||||
return c.json({ error: 'Not authorized to update this offer' }, 403);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await storage.updateOfferLastSeen(offerId, now);
|
||||
|
||||
return c.json({
|
||||
id: offerId,
|
||||
lastSeen: now
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error updating offer heartbeat:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /offers/:offerId
|
||||
* Delete a specific offer
|
||||
* Requires authentication and ownership
|
||||
*/
|
||||
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
const deleted = await storage.deleteOffer(offerId, peerId);
|
||||
|
||||
if (!deleted) {
|
||||
return c.json({ error: 'Offer not found or not authorized' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ deleted: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error deleting offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/answer
|
||||
* Answer a specific offer (locks it to answerer)
|
||||
* Requires authentication
|
||||
*/
|
||||
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const body = await c.req.json();
|
||||
const { sdp } = body;
|
||||
|
||||
if (!sdp || typeof sdp !== 'string') {
|
||||
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
|
||||
}
|
||||
|
||||
if (sdp.length > 65536) {
|
||||
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
||||
}
|
||||
|
||||
const result = await storage.answerOffer(offerId, peerId, sdp);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
answererId: peerId,
|
||||
answeredAt: Date.now()
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error answering offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/answers
|
||||
* Poll for answers to all of authenticated peer's offers
|
||||
* Requires authentication (offerer)
|
||||
*/
|
||||
app.get('/offers/answers', authMiddleware, async (c) => {
|
||||
try {
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const offers = await storage.getAnsweredOffers(peerId);
|
||||
|
||||
return c.json({
|
||||
answers: offers.map(o => ({
|
||||
offerId: o.id,
|
||||
answererId: o.answererPeerId,
|
||||
sdp: o.answerSdp,
|
||||
answeredAt: o.answeredAt,
|
||||
topics: o.topics
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching answers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/ice-candidates
|
||||
* Post ICE candidates for an offer
|
||||
* Requires authentication (must be offerer or answerer)
|
||||
*/
|
||||
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const body = await c.req.json();
|
||||
const { candidates } = body;
|
||||
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
|
||||
}
|
||||
|
||||
// Verify offer exists and caller is offerer or answerer
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
let role: 'offerer' | 'answerer';
|
||||
if (offer.peerId === peerId) {
|
||||
role = 'offerer';
|
||||
} else if (offer.answererPeerId === peerId) {
|
||||
role = 'answerer';
|
||||
} else {
|
||||
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
|
||||
}
|
||||
|
||||
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
candidatesAdded: added
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error adding ICE candidates:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/:offerId/ice-candidates
|
||||
* Poll for ICE candidates from the other peer
|
||||
* Requires authentication (must be offerer or answerer)
|
||||
*/
|
||||
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const sinceParam = c.req.query('since');
|
||||
|
||||
const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
|
||||
|
||||
// Verify offer exists and caller is offerer or answerer
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found or expired' }, 404);
|
||||
}
|
||||
|
||||
let targetRole: 'offerer' | 'answerer';
|
||||
if (offer.peerId === peerId) {
|
||||
// Offerer wants answerer's candidates
|
||||
targetRole = 'answerer';
|
||||
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
|
||||
} else if (offer.answererPeerId === peerId) {
|
||||
// Answerer wants offerer's candidates
|
||||
targetRole = 'offerer';
|
||||
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
|
||||
} else {
|
||||
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
|
||||
}
|
||||
|
||||
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
|
||||
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
|
||||
|
||||
return c.json({
|
||||
offerId,
|
||||
candidates: candidates.map(c => ({
|
||||
candidate: c.candidate,
|
||||
peerId: c.peerId,
|
||||
role: c.role,
|
||||
createdAt: c.createdAt
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error fetching ICE candidates:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
66
src/bloom.ts
Normal file
66
src/bloom.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Bloom filter utility for testing if peer IDs might be in a set
|
||||
* Used to filter out known peers from discovery results
|
||||
*/
|
||||
|
||||
export class BloomFilter {
|
||||
private bits: Uint8Array;
|
||||
private size: number;
|
||||
private numHashes: number;
|
||||
|
||||
/**
|
||||
* Creates a bloom filter from a base64 encoded bit array
|
||||
*/
|
||||
constructor(base64Data: string, numHashes: number = 3) {
|
||||
// Decode base64 to Uint8Array (works in both Node.js and Workers)
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
this.bits = bytes;
|
||||
this.size = this.bits.length * 8;
|
||||
this.numHashes = numHashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a peer ID might be in the filter
|
||||
* Returns true if possibly in set, false if definitely not in set
|
||||
*/
|
||||
test(peerId: string): boolean {
|
||||
for (let i = 0; i < this.numHashes; i++) {
|
||||
const hash = this.hash(peerId, i);
|
||||
const index = hash % this.size;
|
||||
const byteIndex = Math.floor(index / 8);
|
||||
const bitIndex = index % 8;
|
||||
|
||||
if (!(this.bits[byteIndex] & (1 << bitIndex))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function (FNV-1a variant)
|
||||
*/
|
||||
private hash(str: string, seed: number): number {
|
||||
let hash = 2166136261 ^ seed;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse bloom filter from base64 string
|
||||
*/
|
||||
export function parseBloomFilter(base64: string): BloomFilter | null {
|
||||
try {
|
||||
return new BloomFilter(base64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
51
src/config.ts
Normal file
51
src/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { generateSecretKey } from './crypto.ts';
|
||||
|
||||
/**
|
||||
* Application configuration
|
||||
* Reads from environment variables with sensible defaults
|
||||
*/
|
||||
export interface Config {
|
||||
port: number;
|
||||
storageType: 'sqlite' | 'memory';
|
||||
storagePath: string;
|
||||
corsOrigins: string[];
|
||||
version: string;
|
||||
authSecret: string;
|
||||
offerDefaultTtl: number;
|
||||
offerMaxTtl: number;
|
||||
offerMinTtl: number;
|
||||
cleanupInterval: number;
|
||||
maxOffersPerRequest: number;
|
||||
maxTopicsPerOffer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from environment variables
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
// Generate or load auth secret
|
||||
let authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) {
|
||||
authSecret = generateSecretKey();
|
||||
console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret);
|
||||
console.warn('All peer credentials will be invalidated on server restart.');
|
||||
console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.');
|
||||
}
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
|
||||
storagePath: process.env.STORAGE_PATH || ':memory:',
|
||||
corsOrigins: process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'],
|
||||
version: process.env.VERSION || 'unknown',
|
||||
authSecret,
|
||||
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
|
||||
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
|
||||
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
|
||||
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
|
||||
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10),
|
||||
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
|
||||
};
|
||||
}
|
||||
149
src/crypto.ts
Normal file
149
src/crypto.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Crypto utilities for stateless peer authentication
|
||||
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'AES-GCM';
|
||||
const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
|
||||
/**
|
||||
* Generates a random peer ID (16 bytes = 32 hex chars)
|
||||
*/
|
||||
export function generatePeerId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random secret key for encryption (32 bytes = 64 hex chars)
|
||||
*/
|
||||
export function generateSecretKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, (byte) =>
|
||||
String.fromCodePoint(byte)
|
||||
).join('');
|
||||
return btoa(binString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Uint8Array
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binString = atob(base64);
|
||||
return Uint8Array.from(binString, (char) => char.codePointAt(0)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a peer ID using the server secret key
|
||||
* Returns base64-encoded encrypted data (IV + ciphertext)
|
||||
*/
|
||||
export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise<string> {
|
||||
const keyBytes = hexToBytes(secretKeyHex);
|
||||
|
||||
if (keyBytes.length !== KEY_LENGTH) {
|
||||
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
||||
}
|
||||
|
||||
// Import key
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Generate random IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
|
||||
// Encrypt peer ID
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(peerId);
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv },
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Combine IV + ciphertext and encode as base64
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
return bytesToBase64(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an encrypted peer ID secret
|
||||
* Returns the plaintext peer ID or throws if decryption fails
|
||||
*/
|
||||
export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise<string> {
|
||||
try {
|
||||
const keyBytes = hexToBytes(secretKeyHex);
|
||||
|
||||
if (keyBytes.length !== KEY_LENGTH) {
|
||||
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const combined = base64ToBytes(encryptedSecret);
|
||||
|
||||
// Extract IV and ciphertext
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const ciphertext = combined.slice(IV_LENGTH);
|
||||
|
||||
// Import key
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: ALGORITHM, length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (err) {
|
||||
throw new Error('Failed to decrypt peer ID: invalid secret or secret key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a peer ID and secret match
|
||||
* Returns true if valid, false otherwise
|
||||
*/
|
||||
export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise<boolean> {
|
||||
try {
|
||||
const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
|
||||
return decryptedPeerId === peerId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
75
src/index.ts
Normal file
75
src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { SQLiteStorage } from './storage/sqlite.ts';
|
||||
import { Storage } from './storage/types.ts';
|
||||
|
||||
/**
|
||||
* Main entry point for the standalone Node.js server
|
||||
*/
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
console.log('Starting Rondevu server...');
|
||||
console.log('Configuration:', {
|
||||
port: config.port,
|
||||
storageType: config.storageType,
|
||||
storagePath: config.storagePath,
|
||||
offerDefaultTtl: `${config.offerDefaultTtl}ms`,
|
||||
offerMaxTtl: `${config.offerMaxTtl}ms`,
|
||||
offerMinTtl: `${config.offerMinTtl}ms`,
|
||||
cleanupInterval: `${config.cleanupInterval}ms`,
|
||||
maxOffersPerRequest: config.maxOffersPerRequest,
|
||||
maxTopicsPerOffer: config.maxTopicsPerOffer,
|
||||
corsOrigins: config.corsOrigins,
|
||||
version: config.version,
|
||||
});
|
||||
|
||||
let storage: Storage;
|
||||
|
||||
if (config.storageType === 'sqlite') {
|
||||
storage = new SQLiteStorage(config.storagePath);
|
||||
console.log('Using SQLite storage');
|
||||
} else {
|
||||
throw new Error('Unsupported storage type');
|
||||
}
|
||||
|
||||
// Start periodic cleanup of expired offers
|
||||
const cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const deleted = await storage.deleteExpiredOffers(now);
|
||||
if (deleted > 0) {
|
||||
console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cleanup error:', err);
|
||||
}
|
||||
}, config.cleanupInterval);
|
||||
|
||||
const app = createApp(storage, config);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: config.port,
|
||||
});
|
||||
|
||||
console.log(`Server running on http://localhost:${config.port}`);
|
||||
console.log('Ready to accept connections');
|
||||
|
||||
// Graceful shutdown handler
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
clearInterval(cleanupInterval);
|
||||
await storage.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
51
src/middleware/auth.ts
Normal file
51
src/middleware/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { validateCredentials } from '../crypto.ts';
|
||||
|
||||
/**
|
||||
* Authentication middleware for Rondevu
|
||||
* Validates Bearer token in format: {peerId}:{encryptedSecret}
|
||||
*/
|
||||
export function createAuthMiddleware(authSecret: string) {
|
||||
return async (c: Context, next: Next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
// Expect format: Bearer {peerId}:{secret}
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401);
|
||||
}
|
||||
|
||||
const credentials = parts[1].split(':');
|
||||
if (credentials.length !== 2) {
|
||||
return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401);
|
||||
}
|
||||
|
||||
const [peerId, encryptedSecret] = credentials;
|
||||
|
||||
// Validate credentials (async operation)
|
||||
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Attach peer ID to context for use in handlers
|
||||
c.set('peerId', peerId);
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get authenticated peer ID from context
|
||||
*/
|
||||
export function getAuthenticatedPeerId(c: Context): string {
|
||||
const peerId = c.get('peerId');
|
||||
if (!peerId) {
|
||||
throw new Error('No authenticated peer ID in context');
|
||||
}
|
||||
return peerId;
|
||||
}
|
||||
371
src/storage/d1.ts
Normal file
371
src/storage/d1.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
||||
import { generateOfferHash } from './hash-id.ts';
|
||||
|
||||
/**
|
||||
* D1 storage adapter for topic-based offer management using Cloudflare D1
|
||||
* NOTE: This implementation is a placeholder and needs to be fully tested
|
||||
*/
|
||||
export class D1Storage implements Storage {
|
||||
private db: D1Database;
|
||||
|
||||
/**
|
||||
* Creates a new D1 storage instance
|
||||
* @param db D1Database instance from Cloudflare Workers environment
|
||||
*/
|
||||
constructor(db: D1Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database schema with new topic-based structure
|
||||
* This should be run once during setup, not on every request
|
||||
*/
|
||||
async initializeDatabase(): Promise<void> {
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL,
|
||||
sdp TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
answerer_peer_id TEXT,
|
||||
answer_sdp TEXT,
|
||||
answered_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offer_topics (
|
||||
offer_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
PRIMARY KEY (offer_id, topic),
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ice_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
offer_id TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
||||
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
||||
const created: Offer[] = [];
|
||||
|
||||
// D1 doesn't support true transactions yet, so we do this sequentially
|
||||
for (const offer of offers) {
|
||||
const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
|
||||
const now = Date.now();
|
||||
|
||||
// Insert offer
|
||||
await this.db.prepare(`
|
||||
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now).run();
|
||||
|
||||
// Insert topics
|
||||
for (const topic of offer.topics) {
|
||||
await this.db.prepare(`
|
||||
INSERT INTO offer_topics (offer_id, topic)
|
||||
VALUES (?, ?)
|
||||
`).bind(id, topic).run();
|
||||
}
|
||||
|
||||
created.push({
|
||||
id,
|
||||
peerId: offer.peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
createdAt: now,
|
||||
expiresAt: offer.expiresAt,
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
||||
let query = `
|
||||
SELECT DISTINCT o.*
|
||||
FROM offers o
|
||||
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
||||
WHERE ot.topic = ? AND o.expires_at > ?
|
||||
`;
|
||||
|
||||
const params: any[] = [topic, Date.now()];
|
||||
|
||||
if (excludePeerIds && excludePeerIds.length > 0) {
|
||||
const placeholders = excludePeerIds.map(() => '?').join(',');
|
||||
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
||||
params.push(...excludePeerIds);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.last_seen DESC';
|
||||
|
||||
const result = await this.db.prepare(query).bind(...params).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND expires_at > ?
|
||||
ORDER BY last_seen DESC
|
||||
`).bind(peerId, Date.now()).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async getOfferById(offerId: string): Promise<Offer | null> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`).bind(offerId, Date.now()).first();
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToOffer(result as any);
|
||||
}
|
||||
|
||||
async updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void> {
|
||||
await this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET last_seen = ?
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`).bind(lastSeen, offerId, Date.now()).run();
|
||||
}
|
||||
|
||||
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
||||
const result = await this.db.prepare(`
|
||||
DELETE FROM offers
|
||||
WHERE id = ? AND peer_id = ?
|
||||
`).bind(offerId, ownerPeerId).run();
|
||||
|
||||
return (result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredOffers(now: number): Promise<number> {
|
||||
const result = await this.db.prepare(`
|
||||
DELETE FROM offers WHERE expires_at < ?
|
||||
`).bind(now).run();
|
||||
|
||||
return result.meta.changes || 0;
|
||||
}
|
||||
|
||||
async answerOffer(
|
||||
offerId: string,
|
||||
answererPeerId: string,
|
||||
answerSdp: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if offer exists and is not expired
|
||||
const offer = await this.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer not found or expired'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if offer already has an answerer
|
||||
if (offer.answererPeerId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered'
|
||||
};
|
||||
}
|
||||
|
||||
// Update offer with answer
|
||||
const result = await this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
||||
WHERE id = ? AND answerer_peer_id IS NULL
|
||||
`).bind(answererPeerId, answerSdp, Date.now(), offerId).run();
|
||||
|
||||
if ((result.meta.changes || 0) === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered (race condition)'
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
||||
const result = await this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
||||
ORDER BY answered_at DESC
|
||||
`).bind(offererPeerId, Date.now()).all();
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
||||
}
|
||||
|
||||
async addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number> {
|
||||
console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
|
||||
|
||||
// Give each candidate a unique timestamp to avoid "since" filtering issues
|
||||
// D1 doesn't have transactions, so insert one by one
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const timestamp = Date.now() + i; // Ensure unique timestamps
|
||||
await this.db.prepare(`
|
||||
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).bind(
|
||||
offerId,
|
||||
peerId,
|
||||
role,
|
||||
JSON.stringify(candidates[i]), // Store full object as JSON
|
||||
timestamp
|
||||
).run();
|
||||
}
|
||||
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
async getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]> {
|
||||
let query = `
|
||||
SELECT * FROM ice_candidates
|
||||
WHERE offer_id = ? AND role = ?
|
||||
`;
|
||||
|
||||
const params: any[] = [offerId, targetRole];
|
||||
|
||||
if (since !== undefined) {
|
||||
query += ' AND created_at > ?';
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at ASC';
|
||||
|
||||
console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
|
||||
const result = await this.db.prepare(query).bind(...params).all();
|
||||
console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
|
||||
|
||||
if (!result.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = result.results.map((row: any) => ({
|
||||
id: row.id,
|
||||
offerId: row.offer_id,
|
||||
peerId: row.peer_id,
|
||||
role: row.role,
|
||||
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
|
||||
if (candidates.length > 0) {
|
||||
console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}> {
|
||||
// Get total count of topics with active offers
|
||||
const countResult = await this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT ot.topic) as count
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
`).bind(Date.now()).first();
|
||||
|
||||
const total = (countResult as any)?.count || 0;
|
||||
|
||||
// Get topics with peer counts (paginated)
|
||||
const topicsResult = await this.db.prepare(`
|
||||
SELECT
|
||||
ot.topic,
|
||||
COUNT(DISTINCT o.peer_id) as active_peers
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
GROUP BY ot.topic
|
||||
ORDER BY active_peers DESC, ot.topic ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`).bind(Date.now(), limit, offset).all();
|
||||
|
||||
const topics = (topicsResult.results || []).map((row: any) => ({
|
||||
topic: row.topic,
|
||||
activePeers: row.active_peers,
|
||||
}));
|
||||
|
||||
return { topics, total };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// D1 doesn't require explicit connection closing
|
||||
// Connections are managed by the Cloudflare Workers runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert database row to Offer object with topics
|
||||
*/
|
||||
private async rowToOffer(row: any): Promise<Offer> {
|
||||
// Get topics for this offer
|
||||
const topicResult = await this.db.prepare(`
|
||||
SELECT topic FROM offer_topics WHERE offer_id = ?
|
||||
`).bind(row.id).all();
|
||||
|
||||
const topics = topicResult.results?.map((t: any) => t.topic) || [];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
peerId: row.peer_id,
|
||||
sdp: row.sdp,
|
||||
topics,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeen: row.last_seen,
|
||||
answererPeerId: row.answerer_peer_id || undefined,
|
||||
answerSdp: row.answer_sdp || undefined,
|
||||
answeredAt: row.answered_at || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
src/storage/hash-id.ts
Normal file
37
src/storage/hash-id.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Generates a content-based offer ID using SHA-256 hash
|
||||
* Creates deterministic IDs based on offer content (sdp, topics)
|
||||
* PeerID is not included as it's inferred from authentication
|
||||
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
||||
*
|
||||
* @param sdp - The WebRTC SDP offer
|
||||
* @param topics - Array of topic strings
|
||||
* @returns SHA-256 hash of the sanitized offer content
|
||||
*/
|
||||
export async function generateOfferHash(
|
||||
sdp: string,
|
||||
topics: string[]
|
||||
): Promise<string> {
|
||||
// Sanitize and normalize the offer content
|
||||
// Only include core offer content (not peerId - that's inferred from auth)
|
||||
const sanitizedOffer = {
|
||||
sdp,
|
||||
topics: [...topics].sort(), // Sort topics for consistency
|
||||
};
|
||||
|
||||
// Create non-prettified JSON string
|
||||
const jsonString = JSON.stringify(sanitizedOffer);
|
||||
|
||||
// Convert string to Uint8Array for hashing
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(jsonString);
|
||||
|
||||
// Generate SHA-256 hash
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
// Convert hash to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return hashHex;
|
||||
}
|
||||
385
src/storage/sqlite.ts
Normal file
385
src/storage/sqlite.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
||||
import { generateOfferHash } from './hash-id.ts';
|
||||
|
||||
/**
|
||||
* SQLite storage adapter for topic-based offer management
|
||||
* Supports both file-based and in-memory databases
|
||||
*/
|
||||
export class SQLiteStorage implements Storage {
|
||||
private db: Database.Database;
|
||||
|
||||
/**
|
||||
* Creates a new SQLite storage instance
|
||||
* @param path Path to SQLite database file, or ':memory:' for in-memory database
|
||||
*/
|
||||
constructor(path: string = ':memory:') {
|
||||
this.db = new Database(path);
|
||||
this.initializeDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database schema with new topic-based structure
|
||||
*/
|
||||
private initializeDatabase(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id TEXT PRIMARY KEY,
|
||||
peer_id TEXT NOT NULL,
|
||||
sdp TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
answerer_peer_id TEXT,
|
||||
answer_sdp TEXT,
|
||||
answered_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offer_topics (
|
||||
offer_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
PRIMARY KEY (offer_id, topic),
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ice_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
offer_id TEXT NOT NULL,
|
||||
peer_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
||||
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
||||
`);
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
||||
const created: Offer[] = [];
|
||||
|
||||
// Generate hash-based IDs for all offers first
|
||||
const offersWithIds = await Promise.all(
|
||||
offers.map(async (offer) => ({
|
||||
...offer,
|
||||
id: offer.id || await generateOfferHash(offer.sdp, offer.topics),
|
||||
}))
|
||||
);
|
||||
|
||||
// Use transaction for atomic creation
|
||||
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
|
||||
const offerStmt = this.db.prepare(`
|
||||
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const topicStmt = this.db.prepare(`
|
||||
INSERT INTO offer_topics (offer_id, topic)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
for (const offer of offersWithIds) {
|
||||
const now = Date.now();
|
||||
|
||||
// Insert offer
|
||||
offerStmt.run(
|
||||
offer.id,
|
||||
offer.peerId,
|
||||
offer.sdp,
|
||||
now,
|
||||
offer.expiresAt,
|
||||
now
|
||||
);
|
||||
|
||||
// Insert topics
|
||||
for (const topic of offer.topics) {
|
||||
topicStmt.run(offer.id, topic);
|
||||
}
|
||||
|
||||
created.push({
|
||||
id: offer.id,
|
||||
peerId: offer.peerId,
|
||||
sdp: offer.sdp,
|
||||
topics: offer.topics,
|
||||
createdAt: now,
|
||||
expiresAt: offer.expiresAt,
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
transaction(offersWithIds);
|
||||
return created;
|
||||
}
|
||||
|
||||
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
||||
let query = `
|
||||
SELECT DISTINCT o.*
|
||||
FROM offers o
|
||||
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
||||
WHERE ot.topic = ? AND o.expires_at > ?
|
||||
`;
|
||||
|
||||
const params: any[] = [topic, Date.now()];
|
||||
|
||||
if (excludePeerIds && excludePeerIds.length > 0) {
|
||||
const placeholders = excludePeerIds.map(() => '?').join(',');
|
||||
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
||||
params.push(...excludePeerIds);
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.last_seen DESC';
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as any[];
|
||||
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND expires_at > ?
|
||||
ORDER BY last_seen DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(peerId, Date.now()) as any[];
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async getOfferById(offerId: string): Promise<Offer | null> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`);
|
||||
|
||||
const row = stmt.get(offerId, Date.now()) as any;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToOffer(row);
|
||||
}
|
||||
|
||||
async updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void> {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET last_seen = ?
|
||||
WHERE id = ? AND expires_at > ?
|
||||
`);
|
||||
|
||||
stmt.run(lastSeen, offerId, Date.now());
|
||||
}
|
||||
|
||||
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM offers
|
||||
WHERE id = ? AND peer_id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(offerId, ownerPeerId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredOffers(now: number): Promise<number> {
|
||||
const stmt = this.db.prepare('DELETE FROM offers WHERE expires_at < ?');
|
||||
const result = stmt.run(now);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
async answerOffer(
|
||||
offerId: string,
|
||||
answererPeerId: string,
|
||||
answerSdp: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if offer exists and is not expired
|
||||
const offer = await this.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer not found or expired'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if offer already has an answerer
|
||||
if (offer.answererPeerId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered'
|
||||
};
|
||||
}
|
||||
|
||||
// Update offer with answer
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE offers
|
||||
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
||||
WHERE id = ? AND answerer_peer_id IS NULL
|
||||
`);
|
||||
|
||||
const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Offer already answered (race condition)'
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM offers
|
||||
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
||||
ORDER BY answered_at DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(offererPeerId, Date.now()) as any[];
|
||||
return Promise.all(rows.map(row => this.rowToOffer(row)));
|
||||
}
|
||||
|
||||
async addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number> {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const baseTimestamp = Date.now();
|
||||
const transaction = this.db.transaction((candidates: any[]) => {
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
stmt.run(
|
||||
offerId,
|
||||
peerId,
|
||||
role,
|
||||
JSON.stringify(candidates[i]), // Store full object as JSON
|
||||
baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
transaction(candidates);
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
async getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]> {
|
||||
let query = `
|
||||
SELECT * FROM ice_candidates
|
||||
WHERE offer_id = ? AND role = ?
|
||||
`;
|
||||
|
||||
const params: any[] = [offerId, targetRole];
|
||||
|
||||
if (since !== undefined) {
|
||||
query += ' AND created_at > ?';
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at ASC';
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
offerId: row.offer_id,
|
||||
peerId: row.peer_id,
|
||||
role: row.role,
|
||||
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}> {
|
||||
// Get total count of topics with active offers
|
||||
const countStmt = this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT ot.topic) as count
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
`);
|
||||
|
||||
const countRow = countStmt.get(Date.now()) as any;
|
||||
const total = countRow.count;
|
||||
|
||||
// Get topics with peer counts (paginated)
|
||||
const topicsStmt = this.db.prepare(`
|
||||
SELECT
|
||||
ot.topic,
|
||||
COUNT(DISTINCT o.peer_id) as active_peers
|
||||
FROM offer_topics ot
|
||||
INNER JOIN offers o ON ot.offer_id = o.id
|
||||
WHERE o.expires_at > ?
|
||||
GROUP BY ot.topic
|
||||
ORDER BY active_peers DESC, ot.topic ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const rows = topicsStmt.all(Date.now(), limit, offset) as any[];
|
||||
|
||||
const topics = rows.map(row => ({
|
||||
topic: row.topic,
|
||||
activePeers: row.active_peers,
|
||||
}));
|
||||
|
||||
return { topics, total };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert database row to Offer object with topics
|
||||
*/
|
||||
private async rowToOffer(row: any): Promise<Offer> {
|
||||
// Get topics for this offer
|
||||
const topicStmt = this.db.prepare(`
|
||||
SELECT topic FROM offer_topics WHERE offer_id = ?
|
||||
`);
|
||||
|
||||
const topicRows = topicStmt.all(row.id) as any[];
|
||||
const topics = topicRows.map(t => t.topic);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
peerId: row.peer_id,
|
||||
sdp: row.sdp,
|
||||
topics,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeen: row.last_seen,
|
||||
answererPeerId: row.answerer_peer_id || undefined,
|
||||
answerSdp: row.answer_sdp || undefined,
|
||||
answeredAt: row.answered_at || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
src/storage/types.ts
Normal file
167
src/storage/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Represents a WebRTC signaling offer with topic-based discovery
|
||||
*/
|
||||
export interface Offer {
|
||||
id: string;
|
||||
peerId: string;
|
||||
sdp: string;
|
||||
topics: string[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
lastSeen: number;
|
||||
answererPeerId?: string;
|
||||
answerSdp?: string;
|
||||
answeredAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an ICE candidate for WebRTC signaling
|
||||
* Stores the complete candidate object as plain JSON (no type enforcement)
|
||||
*/
|
||||
export interface IceCandidate {
|
||||
id: number;
|
||||
offerId: string;
|
||||
peerId: string;
|
||||
role: 'offerer' | 'answerer';
|
||||
candidate: any; // Full candidate object as JSON - don't enforce structure
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a topic with active peer count
|
||||
*/
|
||||
export interface TopicInfo {
|
||||
topic: string;
|
||||
activePeers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a new offer
|
||||
*/
|
||||
export interface CreateOfferRequest {
|
||||
id?: string;
|
||||
peerId: string;
|
||||
sdp: string;
|
||||
topics: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage interface for offer management with topic-based discovery
|
||||
* Implementations can use different backends (SQLite, D1, Memory, etc.)
|
||||
*/
|
||||
export interface Storage {
|
||||
/**
|
||||
* Creates one or more offers
|
||||
* @param offers Array of offer creation requests
|
||||
* @returns Array of created offers with IDs
|
||||
*/
|
||||
createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves offers by topic with optional peer ID exclusion
|
||||
* @param topic Topic to search for
|
||||
* @param excludePeerIds Optional array of peer IDs to exclude
|
||||
* @returns Array of offers matching the topic
|
||||
*/
|
||||
getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves all offers from a specific peer
|
||||
* @param peerId Peer identifier
|
||||
* @returns Array of offers from the peer
|
||||
*/
|
||||
getOffersByPeerId(peerId: string): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific offer by ID
|
||||
* @param offerId Offer identifier
|
||||
* @returns The offer if found, null otherwise
|
||||
*/
|
||||
getOfferById(offerId: string): Promise<Offer | null>;
|
||||
|
||||
/**
|
||||
* Updates the last_seen timestamp for an offer (heartbeat)
|
||||
* @param offerId Offer identifier
|
||||
* @param lastSeen New last_seen timestamp
|
||||
*/
|
||||
updateOfferLastSeen(offerId: string, lastSeen: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes an offer (with ownership verification)
|
||||
* @param offerId Offer identifier
|
||||
* @param ownerPeerId Peer ID of the owner (for verification)
|
||||
* @returns true if deleted, false if not found or not owned
|
||||
*/
|
||||
deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Deletes all expired offers
|
||||
* @param now Current timestamp
|
||||
* @returns Number of offers deleted
|
||||
*/
|
||||
deleteExpiredOffers(now: number): Promise<number>;
|
||||
|
||||
/**
|
||||
* Answers an offer (locks it to the answerer)
|
||||
* @param offerId Offer identifier
|
||||
* @param answererPeerId Answerer's peer ID
|
||||
* @param answerSdp WebRTC answer SDP
|
||||
* @returns Success status and optional error message
|
||||
*/
|
||||
answerOffer(offerId: string, answererPeerId: string, answerSdp: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Retrieves all answered offers for a specific offerer
|
||||
* @param offererPeerId Offerer's peer ID
|
||||
* @returns Array of answered offers
|
||||
*/
|
||||
getAnsweredOffers(offererPeerId: string): Promise<Offer[]>;
|
||||
|
||||
/**
|
||||
* Adds ICE candidates for an offer
|
||||
* @param offerId Offer identifier
|
||||
* @param peerId Peer ID posting the candidates
|
||||
* @param role Role of the peer (offerer or answerer)
|
||||
* @param candidates Array of candidate objects (stored as plain JSON)
|
||||
* @returns Number of candidates added
|
||||
*/
|
||||
addIceCandidates(
|
||||
offerId: string,
|
||||
peerId: string,
|
||||
role: 'offerer' | 'answerer',
|
||||
candidates: any[]
|
||||
): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieves ICE candidates for an offer
|
||||
* @param offerId Offer identifier
|
||||
* @param targetRole Role to retrieve candidates for (offerer or answerer)
|
||||
* @param since Optional timestamp - only return candidates after this time
|
||||
* @returns Array of ICE candidates
|
||||
*/
|
||||
getIceCandidates(
|
||||
offerId: string,
|
||||
targetRole: 'offerer' | 'answerer',
|
||||
since?: number
|
||||
): Promise<IceCandidate[]>;
|
||||
|
||||
/**
|
||||
* Retrieves topics with active peer counts (paginated)
|
||||
* @param limit Maximum number of topics to return
|
||||
* @param offset Number of topics to skip
|
||||
* @returns Object with topics array and total count
|
||||
*/
|
||||
getTopics(limit: number, offset: number): Promise<{
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Closes the storage connection and releases resources
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
74
src/worker.ts
Normal file
74
src/worker.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createApp } from './app.ts';
|
||||
import { D1Storage } from './storage/d1.ts';
|
||||
import { generateSecretKey } from './crypto.ts';
|
||||
import { Config } from './config.ts';
|
||||
|
||||
/**
|
||||
* Cloudflare Workers environment bindings
|
||||
*/
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
AUTH_SECRET?: string;
|
||||
OFFER_DEFAULT_TTL?: string;
|
||||
OFFER_MAX_TTL?: string;
|
||||
OFFER_MIN_TTL?: string;
|
||||
MAX_OFFERS_PER_REQUEST?: string;
|
||||
MAX_TOPICS_PER_OFFER?: string;
|
||||
CORS_ORIGINS?: string;
|
||||
VERSION?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers fetch handler
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Initialize D1 storage
|
||||
const storage = new D1Storage(env.DB);
|
||||
|
||||
// Generate or use provided auth secret
|
||||
const authSecret = env.AUTH_SECRET || generateSecretKey();
|
||||
|
||||
// Build config from environment
|
||||
const config: Config = {
|
||||
port: 0, // Not used in Workers
|
||||
storageType: 'sqlite', // D1 is SQLite-compatible
|
||||
storagePath: '', // Not used with D1
|
||||
corsOrigins: env.CORS_ORIGINS
|
||||
? env.CORS_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['*'],
|
||||
version: env.VERSION || 'unknown',
|
||||
authSecret,
|
||||
offerDefaultTtl: env.OFFER_DEFAULT_TTL ? parseInt(env.OFFER_DEFAULT_TTL, 10) : 60000,
|
||||
offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000,
|
||||
offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000,
|
||||
cleanupInterval: 60000, // Not used in Workers (scheduled handler instead)
|
||||
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100,
|
||||
maxTopicsPerOffer: env.MAX_TOPICS_PER_OFFER ? parseInt(env.MAX_TOPICS_PER_OFFER, 10) : 50,
|
||||
};
|
||||
|
||||
// Create Hono app
|
||||
const app = createApp(storage, config);
|
||||
|
||||
// Handle request
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Scheduled handler for cron triggers
|
||||
* Runs periodically to clean up expired offers
|
||||
*/
|
||||
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const storage = new D1Storage(env.DB);
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
// Delete expired offers
|
||||
const deletedCount = await storage.deleteExpiredOffers(now);
|
||||
|
||||
console.log(`Cleaned up ${deletedCount} expired offers at ${new Date(now).toISOString()}`);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up offers:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["@types/node", "@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
45
wrangler.toml
Normal file
45
wrangler.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
name = "rondevu"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# D1 Database binding
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "rondevu-offers"
|
||||
database_id = "b94e3f71-816d-455b-a89d-927fa49532d0"
|
||||
|
||||
# Environment variables
|
||||
[vars]
|
||||
OFFER_DEFAULT_TTL = "60000" # Default offer TTL: 1 minute
|
||||
OFFER_MAX_TTL = "86400000" # Max offer TTL: 24 hours
|
||||
OFFER_MIN_TTL = "60000" # Min offer TTL: 1 minute
|
||||
MAX_OFFERS_PER_REQUEST = "100" # Max offers per request
|
||||
MAX_TOPICS_PER_OFFER = "50" # Max topics per offer
|
||||
CORS_ORIGINS = "*" # Comma-separated list of allowed origins
|
||||
VERSION = "0.1.0" # Semantic version
|
||||
|
||||
# AUTH_SECRET should be set as a secret, not a var
|
||||
# Run: npx wrangler secret put AUTH_SECRET
|
||||
# Enter a 64-character hex string (32 bytes)
|
||||
|
||||
# Build configuration
|
||||
[build]
|
||||
command = ""
|
||||
|
||||
# For local development:
|
||||
# Run: npx wrangler dev
|
||||
# The local D1 database will be created automatically
|
||||
|
||||
# For production deployment:
|
||||
# 1. Create D1 database: npx wrangler d1 create rondevu-sessions
|
||||
# 2. Update the 'database_id' field above with the returned ID
|
||||
# 3. Initialize schema: npx wrangler d1 execute rondevu-sessions --remote --file=./migrations/schema.sql
|
||||
# 4. Deploy: npx wrangler deploy
|
||||
|
||||
[observability]
|
||||
[observability.logs]
|
||||
enabled = false
|
||||
head_sampling_rate = 1
|
||||
invocation_logs = true
|
||||
persist = true
|
||||
26
wrangler.toml.example
Normal file
26
wrangler.toml.example
Normal file
@@ -0,0 +1,26 @@
|
||||
name = "rondevu"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
# KV Namespace binding
|
||||
[[kv_namespaces]]
|
||||
binding = "SESSIONS"
|
||||
id = "" # Replace with your KV namespace ID
|
||||
|
||||
# Environment variables
|
||||
[vars]
|
||||
SESSION_TIMEOUT = "300000" # 5 minutes in milliseconds
|
||||
CORS_ORIGINS = "*" # Comma-separated list of allowed origins
|
||||
|
||||
# Build configuration
|
||||
[build]
|
||||
command = ""
|
||||
|
||||
# For local development
|
||||
# Run: npx wrangler dev
|
||||
# The local KV will be created automatically
|
||||
|
||||
# For production deployment:
|
||||
# 1. Create KV namespace: npx wrangler kv:namespace create SESSIONS
|
||||
# 2. Update the 'id' field above with the returned namespace ID
|
||||
# 3. Deploy: npx wrangler deploy
|
||||
Reference in New Issue
Block a user