From 82c0e8b065447cb715aefe57c9757b1315bcb7ec Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 2 Nov 2025 14:32:25 +0100 Subject: [PATCH] Initial commit: Rondevu signaling server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open signaling and tracking server for peer discovery in distributed P2P applications. Features: - REST API for WebRTC peer discovery and signaling - Origin-based session isolation - Multiple storage backends (SQLite, in-memory, Cloudflare KV) - Docker and Cloudflare Workers deployment support - Automatic session cleanup and expiration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .dockerignore | 12 + .gitignore | 14 + API.md | 428 +++++++++++++++ DEPLOYMENT.md | 346 ++++++++++++ Dockerfile | 57 ++ README.md | 242 ++++++++ build.js | 17 + package-lock.json | 1216 +++++++++++++++++++++++++++++++++++++++++ package.json | 27 + src/app.ts | 228 ++++++++ src/config.ts | 26 + src/index.ts | 59 ++ src/storage/kv.ts | 241 ++++++++ src/storage/sqlite.ts | 258 +++++++++ src/storage/types.ts | 90 +++ src/worker.ts | 39 ++ tsconfig.json | 20 + wrangler.toml.example | 26 + 18 files changed, 3346 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 API.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/storage/kv.ts create mode 100644 src/storage/sqlite.ts create mode 100644 src/storage/types.ts create mode 100644 src/worker.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml.example diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f085da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +*.log +.git +.gitignore +.env +README.md +API.md +.DS_Store +*.db +*.db-journal +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab165f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +*.db +*.db-journal +data/ + +# Wrangler / Cloudflare Workers +.wrangler/ +.dev.vars +wrangler.toml.backup +wrangler.toml diff --git a/API.md b/API.md new file mode 100644 index 0000000..3fca8af --- /dev/null +++ b/API.md @@ -0,0 +1,428 @@ +# 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 `/` + +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/ \ + -H "Origin: https://example.com" +``` + +**Custom pagination:** +```bash +curl -X GET "http://localhost:3000/?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": "", + "offerCandidates": [""], + "createdAt": 1699564800000, + "expiresAt": 1699565100000 + }, + { + "code": "660e8400-e29b-41d4-a716-446655440001", + "info": "peer-456", + "offer": "", + "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": "" + }' + +# 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": "", + "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": "", + "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": "", + "answerCandidates": [ + "", + "" + ] +} +``` + +**For `side=answerer` (the answerer polls for offer from offerer):** +```json +{ + "offer": "", + "offerCandidates": [ + "", + "" + ] +} +``` + +**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": "", +# "offerCandidates": [""] +# } +``` + +**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": "", +# "answerCandidates": [""] +# } +``` + +--- + +## 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. **Discover active topics:** + - GET `/` to see all topics and peer counts + - Optional: paginate through results with `?page=2&limit=100` + +2. **Peer A announces availability:** + - POST `/:topic/offer` with peer identifier and signaling data + - Receives a unique session code + +3. **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 + +4. **Peer B initiates connection:** + - POST `/answer` with the session code and their signaling data + +5. **Both peers exchange signaling information:** + - POST `/answer` with additional signaling data as needed + - POST `/poll` to retrieve signaling data from the other peer + +6. **Peer connection established** + - Peers use exchanged signaling data to establish direct connection + - Session automatically expires after configured timeout diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4c4f15d --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,346 @@ +# Deployment Guide + +This guide covers deploying Rondevu to various platforms. + +## Table of Contents + +- [Cloudflare Workers](#cloudflare-workers) +- [Docker](#docker) +- [Node.js](#nodejs) + +--- + +## Cloudflare Workers + +Deploy to Cloudflare's edge network using Cloudflare Workers and KV storage. + +### Prerequisites + +```bash +npm install -g wrangler +``` + +### Setup + +1. **Login to Cloudflare** + ```bash + wrangler login + ``` + +2. **Create KV Namespace** + ```bash + # For production + wrangler kv:namespace create SESSIONS + + # This will output something like: + # { binding = "SESSIONS", id = "abc123..." } + ``` + +3. **Update wrangler.toml** + + Edit `wrangler.toml` and replace `YOUR_KV_NAMESPACE_ID` with the ID from step 2: + + ```toml + [[kv_namespaces]] + binding = "SESSIONS" + id = "abc123..." # Your actual KV namespace ID + ``` + +4. **Configure Environment Variables** (Optional) + + Update `wrangler.toml` to customize settings: + + ```toml + [vars] + SESSION_TIMEOUT = "300000" # Session timeout in milliseconds + CORS_ORIGINS = "https://example.com,https://app.example.com" + ``` + +### Local Development + +```bash +# Run locally with Wrangler +npx wrangler dev + +# The local development server will: +# - Start on http://localhost:8787 +# - Use a local KV namespace automatically +# - Hot-reload on file changes +``` + +### Production Deployment + +```bash +# Deploy to Cloudflare Workers +npx wrangler deploy + +# This will output your worker URL: +# https://rondevu.YOUR_SUBDOMAIN.workers.dev +``` + +### Custom Domain (Optional) + +1. Go to your Cloudflare Workers dashboard +2. Select your worker +3. Click "Triggers" → "Add Custom Domain" +4. Enter your domain (e.g., `api.example.com`) + +### Monitoring + +View logs and analytics: + +```bash +# Stream real-time logs +npx wrangler tail + +# View in dashboard +# Visit: https://dash.cloudflare.com → Workers & Pages +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SESSION_TIMEOUT` | `300000` | Session timeout in milliseconds | +| `CORS_ORIGINS` | `*` | Comma-separated allowed origins | + +### Pricing + +Cloudflare Workers Free Tier includes: +- 100,000 requests/day +- 10ms CPU time per request +- KV: 100,000 reads/day, 1,000 writes/day + +For higher usage, see [Cloudflare Workers pricing](https://workers.cloudflare.com/#plans). + +### Advantages + +- **Global Edge Network**: Deploy to 300+ locations worldwide +- **Instant Scaling**: Handles traffic spikes automatically +- **Low Latency**: Runs close to your users +- **No Server Management**: Fully serverless +- **Free Tier**: Generous limits for small projects + +--- + +## Docker + +### Quick Start + +```bash +# Build +docker build -t rondevu . + +# Run with in-memory SQLite +docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu + +# Run with persistent SQLite +docker run -p 3000:3000 \ + -v $(pwd)/data:/app/data \ + -e STORAGE_PATH=/app/data/sessions.db \ + rondevu +``` + +### Docker Compose + +Create a `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + rondevu: + build: . + ports: + - "3000:3000" + environment: + - PORT=3000 + - STORAGE_TYPE=sqlite + - STORAGE_PATH=/app/data/sessions.db + - SESSION_TIMEOUT=300000 + - CORS_ORIGINS=* + volumes: + - ./data:/app/data + restart: unless-stopped +``` + +Run with: +```bash +docker-compose up -d +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Server port | +| `STORAGE_TYPE` | `sqlite` | Storage backend | +| `STORAGE_PATH` | `/app/data/sessions.db` | SQLite database path | +| `SESSION_TIMEOUT` | `300000` | Session timeout in ms | +| `CORS_ORIGINS` | `*` | Allowed CORS origins | + +--- + +## Node.js + +### Production Deployment + +1. **Install Dependencies** + ```bash + npm ci --production + ``` + +2. **Build TypeScript** + ```bash + npm run build + ``` + +3. **Set Environment Variables** + ```bash + export PORT=3000 + export STORAGE_TYPE=sqlite + export STORAGE_PATH=./data/sessions.db + export SESSION_TIMEOUT=300000 + export CORS_ORIGINS=* + ``` + +4. **Run** + ```bash + npm start + ``` + +### Process Manager (PM2) + +For production, use a process manager like PM2: + +1. **Install PM2** + ```bash + npm install -g pm2 + ``` + +2. **Create ecosystem.config.js** + ```javascript + module.exports = { + apps: [{ + name: 'rondevu', + script: './dist/index.js', + instances: 'max', + exec_mode: 'cluster', + env: { + NODE_ENV: 'production', + PORT: 3000, + STORAGE_TYPE: 'sqlite', + STORAGE_PATH: './data/sessions.db', + SESSION_TIMEOUT: 300000, + CORS_ORIGINS: '*' + } + }] + }; + ``` + +3. **Start with PM2** + ```bash + pm2 start ecosystem.config.js + pm2 save + pm2 startup + ``` + +### Systemd Service + +Create `/etc/systemd/system/rondevu.service`: + +```ini +[Unit] +Description=Rondevu Peer Discovery and Signaling Server +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/rondevu +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +Environment=PORT=3000 +Environment=STORAGE_TYPE=sqlite +Environment=STORAGE_PATH=/opt/rondevu/data/sessions.db +Environment=SESSION_TIMEOUT=300000 +Environment=CORS_ORIGINS=* + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +sudo systemctl enable rondevu +sudo systemctl start rondevu +sudo systemctl status rondevu +``` + +--- + +## Troubleshooting + +### Docker + +**Issue: Permission denied on /app/data** +- Ensure volume permissions are correct +- The container runs as user `node` (UID 1000) + +**Issue: Database locked** +- Don't share the same SQLite database file across multiple containers +- Use one instance or implement a different storage backend + +### Node.js + +**Issue: EADDRINUSE** +- Port is already in use, change `PORT` environment variable + +**Issue: Database is locked** +- Another process is using the database +- Ensure only one instance is running with the same database file + +--- + +## Performance Tuning + +### Node.js/Docker + +- Set `SESSION_TIMEOUT` appropriately to balance resource usage +- For high traffic, use `STORAGE_PATH=:memory:` with session replication +- Consider horizontal scaling with a shared database backend + +--- + +## Security Considerations + +1. **HTTPS**: Always use HTTPS in production + - Use a reverse proxy (nginx, Caddy) for Node.js deployments + - Docker deployments should be behind a reverse proxy + +2. **Rate Limiting**: Implement rate limiting at the proxy level + +3. **CORS**: Configure CORS origins appropriately + - Don't use `*` in production + - Set specific allowed origins: `https://example.com,https://app.example.com` + +4. **Input Validation**: SDP offers/answers are stored as-is; validate on client side + +5. **Session Codes**: UUID v4 codes provide strong entropy (2^122 combinations) + +6. **Origin Isolation**: Sessions are isolated by Origin header to organize topics by domain + +--- + +## Scaling + +### Horizontal Scaling + +- **Docker/Node.js**: Use a shared database (not SQLite) for multiple instances + - Implement a Redis or PostgreSQL storage adapter + +### Vertical Scaling + +- Increase `SESSION_TIMEOUT` or cleanup frequency as needed +- Monitor database size and connection pool +- For Node.js, monitor memory usage and increase if needed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..096d420 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c12aaa --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# Rondevu + +An open signaling and tracking server for peer discovery. Enables peers to find each other through a topic-based HTTP API with Origin isolation for organizing peer-to-peer applications. + +## Features + +- 🚀 **Fast & Lightweight** - Built with [Hono](https://hono.dev/) framework +- 📂 **Topic-Based Organization** - Group sessions by topic for easy peer discovery +- 🔒 **Origin Isolation** - Sessions are isolated by HTTP Origin header to group topics by domain +- 🏷️ **Peer Identification** - Info field prevents duplicate connections to same peer +- 🔌 **Pluggable Storage** - Storage interface supports SQLite and in-memory adapters +- 🐳 **Docker Ready** - Minimal Alpine-based Docker image +- ⏱️ **Session Timeout** - Configurable session expiration from initiation time +- 🔐 **Type Safe** - Written in TypeScript with full type definitions + +## Quick Start + +### Using Node.js + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run dev + +# Build and run in production +npm run build +npm start +``` + +### Using Docker + +```bash +# Build the image +docker build -t rondevu . + +# Run with default settings (SQLite database) +docker run -p 3000:3000 rondevu + +# Run with in-memory storage +docker run -p 3000:3000 -e STORAGE_TYPE=memory rondevu + +# Run with custom timeout (10 minutes) +docker run -p 3000:3000 -e SESSION_TIMEOUT=600000 rondevu +``` + +### Using Cloudflare Workers + +```bash +# Install Wrangler CLI +npm install -g wrangler + +# Login to Cloudflare +wrangler login + +# Create KV namespace +wrangler kv:namespace create SESSIONS + +# Update wrangler.toml with the KV namespace ID + +# Deploy to Cloudflare's edge network +npx wrangler deploy +``` + +See [DEPLOYMENT.md](./DEPLOYMENT.md#cloudflare-workers) for detailed instructions. + +## Configuration + +Configuration is done through environment variables: + +| Variable | Description | Default | +|--------------------|--------------------------------------------------|-------------| +| `PORT` | Server port | `3000` | +| `STORAGE_TYPE` | Storage backend: `sqlite` or `memory` | `sqlite` | +| `STORAGE_PATH` | Path to SQLite database file | `./data.db` | +| `SESSION_TIMEOUT` | Session timeout in milliseconds | `300000` | +| `CORS_ORIGINS` | Comma-separated list of allowed origins | `*` | + +### Example .env file + +```env +PORT=3000 +STORAGE_TYPE=sqlite +STORAGE_PATH=./sessions.db +SESSION_TIMEOUT=300000 +CORS_ORIGINS=https://example.com,https://app.example.com +``` + +## API Documentation + +See [API.md](./API.md) for complete API documentation. + +### Quick Overview + +**List all active topics (with pagination):** +```bash +curl -X GET http://localhost:3000/ \ + -H "Origin: https://example.com" +# Returns: {"topics":[{"topic":"my-room","count":3}],"pagination":{...}} +``` + +**Create an offer (announce yourself as available):** +```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":""}' +# Returns: {"code":"550e8400-e29b-41d4-a716-446655440000"} +``` + +**List available peers in a topic:** +```bash +curl -X GET http://localhost:3000/my-room/sessions \ + -H "Origin: https://example.com" +# Returns: {"sessions":[...]} +``` + +**Connect to a peer:** +```bash +curl -X POST http://localhost:3000/answer \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{"code":"550e8400-...","answer":"","side":"answerer"}' +# Returns: {"success":true} +``` + +## Architecture + +### Storage Interface + +The storage layer is abstracted through a simple interface, making it easy to implement custom storage backends: + +```typescript +interface Storage { + createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise; + listSessionsByTopic(origin: string, topic: string): Promise; + getSession(code: string, origin: string): Promise; + updateSession(code: string, origin: string, update: Partial): Promise; + deleteSession(code: string): Promise; + cleanup(): Promise; + close(): Promise; +} +``` + +### Built-in Storage Adapters + +**SQLite Storage** (`sqlite.ts`) +- For Node.js/Docker deployments +- Persistent file-based or in-memory +- Automatic session cleanup +- Simple and reliable + +**Cloudflare KV Storage** (`kv.ts`) +- For Cloudflare Workers deployments +- Global edge storage +- Automatic TTL-based expiration +- Distributed and highly available + +### Custom Storage Adapters + +You can implement your own storage adapter by implementing the `Storage` interface: + +```typescript +import { Storage, Session } from './storage/types'; + +export class CustomStorage implements Storage { + async createSession(offer: string, expiresAt: number): Promise { + // Your implementation + } + // ... implement other methods +} +``` + +## Development + +### Project Structure + +``` +rondevu/ +├── src/ +│ ├── index.ts # Node.js server entry point +│ ├── app.ts # Hono application +│ ├── config.ts # Configuration +│ └── storage/ +│ ├── types.ts # Storage interface +│ ├── sqlite.ts # SQLite adapter +│ └── codeGenerator.ts # Code generation utility +├── Dockerfile # Docker build configuration +├── build.js # Build script +├── API.md # API documentation +└── README.md # This file +``` + +### Building + +```bash +# Build TypeScript +npm run build + +# Run built version +npm start +``` + +### Docker Build + +```bash +# Build the image +docker build -t rondevu . + +# Run with volume for persistent storage +docker run -p 3000:3000 -v $(pwd)/data:/app/data rondevu +``` + +## How It Works + +1. **Discover topics** (optional): Call `GET /` to see all active topics and peer counts +2. **Peer A** announces availability by posting to `/:topic/offer` with peer identifier and signaling data +3. Server generates a unique UUID code and stores the session (bucketed by Origin and topic) +4. **Peer B** discovers available peers using `GET /:topic/sessions` +5. **Peer B** filters out their own session using the info field to avoid self-connection +6. **Peer B** selects a peer and posts their connection data to `POST /answer` with the session code +7. Both peers exchange signaling data through `POST /answer` endpoint +8. Both peers poll for updates using `POST /poll` to retrieve connection information +9. Sessions automatically expire after the configured timeout + +This allows peers in distributed systems to discover each other without requiring a centralized registry, while maintaining isolation between different applications through Origin headers. + +### Origin Isolation + +Sessions are isolated by the HTTP `Origin` header, ensuring that: +- Peers can only see sessions from their own origin +- Session codes cannot be accessed cross-origin +- Topics are organized by application domain + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/build.js b/build.js new file mode 100644 index 0000000..c4f0c7c --- /dev/null +++ b/build.js @@ -0,0 +1,17 @@ +// Build script using esbuild +const esbuild = require('esbuild'); + +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, +}).catch(() => process.exit(1)); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a74d62c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1216 @@ +{ + "name": "rondevu", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rondevu", + "version": "1.0.0", + "dependencies": { + "@hono/node-server": "^1.19.6", + "better-sqlite3": "^12.4.1", + "hono": "^4.10.4" + }, + "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" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251014.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251014.0.tgz", + "integrity": "sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", + "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", + "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.4.tgz", + "integrity": "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.80.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz", + "integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5deae46 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "rondevu", + "version": "1.0.0", + "description": "Open signaling and tracking server for peer discovery in 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" + }, + "private": true, + "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" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..b4cb067 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,228 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { Storage } from './storage/types.ts'; + +export interface AppConfig { + sessionTimeout: number; + corsOrigins: string[]; +} + +/** + * Creates the Hono application with WebRTC signaling endpoints + */ +export function createApp(storage: Storage, config: AppConfig) { + const app = new Hono(); + + // Enable CORS + app.use('/*', cors({ + origin: config.corsOrigins, + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type'], + exposeHeaders: ['Content-Type'], + maxAge: 600, + credentials: true, + })); + + /** + * GET / + * Lists all topics with their unanswered session counts (paginated) + * Query params: page (default: 1), limit (default: 100, max: 1000) + */ + app.get('/', async (c) => { + try { + const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown'; + const page = parseInt(c.req.query('page') || '1', 10); + const limit = parseInt(c.req.query('limit') || '100', 10); + + const result = await storage.listTopics(origin, page, limit); + + return c.json(result); + } catch (err) { + console.error('Error listing topics:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /:topic/sessions + * Lists all unanswered sessions for a topic + */ + app.get('/:topic/sessions', async (c) => { + try { + const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown'; + const topic = c.req.param('topic'); + + if (!topic) { + return c.json({ error: 'Missing required parameter: topic' }, 400); + } + + if (topic.length > 256) { + return c.json({ error: 'Topic string must be 256 characters or less' }, 400); + } + + const sessions = await storage.listSessionsByTopic(origin, topic); + + return c.json({ + sessions: sessions.map(s => ({ + code: s.code, + info: s.info, + offer: s.offer, + offerCandidates: s.offerCandidates, + createdAt: s.createdAt, + expiresAt: s.expiresAt, + })), + }); + } catch (err) { + console.error('Error listing sessions:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /:topic/offer + * Creates a new offer and returns a unique session code + * Body: { info: string, offer: string } + */ + app.post('/:topic/offer', async (c) => { + try { + const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown'; + const topic = c.req.param('topic'); + const body = await c.req.json(); + const { info, offer } = body; + + if (!topic || typeof topic !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: topic' }, 400); + } + + if (topic.length > 256) { + return c.json({ error: 'Topic string must be 256 characters or less' }, 400); + } + + if (!info || typeof info !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: info' }, 400); + } + + if (info.length > 1024) { + return c.json({ error: 'Info string must be 1024 characters or less' }, 400); + } + + if (!offer || typeof offer !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: offer' }, 400); + } + + const expiresAt = Date.now() + config.sessionTimeout; + const code = await storage.createSession(origin, topic, info, offer, expiresAt); + + return c.json({ code }, 200); + } catch (err) { + console.error('Error creating offer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /answer + * Responds to an existing offer or sends ICE candidates + * Body: { code: string, answer?: string, candidate?: string, side: 'offerer' | 'answerer' } + */ + app.post('/answer', async (c) => { + try { + const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown'; + const body = await c.req.json(); + const { code, answer, candidate, side } = body; + + if (!code || typeof code !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: code' }, 400); + } + + if (!side || (side !== 'offerer' && side !== 'answerer')) { + return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400); + } + + if (!answer && !candidate) { + return c.json({ error: 'Missing required parameter: answer or candidate' }, 400); + } + + if (answer && candidate) { + return c.json({ error: 'Cannot provide both answer and candidate' }, 400); + } + + const session = await storage.getSession(code, origin); + + if (!session) { + return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404); + } + + if (answer) { + await storage.updateSession(code, origin, { answer }); + } + + if (candidate) { + if (side === 'offerer') { + const updatedCandidates = [...session.offerCandidates, candidate]; + await storage.updateSession(code, origin, { offerCandidates: updatedCandidates }); + } else { + const updatedCandidates = [...session.answerCandidates, candidate]; + await storage.updateSession(code, origin, { answerCandidates: updatedCandidates }); + } + } + + return c.json({ success: true }, 200); + } catch (err) { + console.error('Error handling answer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /poll + * Polls for session data (offer, answer, ICE candidates) + * Body: { code: string, side: 'offerer' | 'answerer' } + */ + app.post('/poll', async (c) => { + try { + const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown'; + const body = await c.req.json(); + const { code, side } = body; + + if (!code || typeof code !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: code' }, 400); + } + + if (!side || (side !== 'offerer' && side !== 'answerer')) { + return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400); + } + + const session = await storage.getSession(code, origin); + + if (!session) { + return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404); + } + + if (side === 'offerer') { + return c.json({ + answer: session.answer || null, + answerCandidates: session.answerCandidates, + }); + } else { + return c.json({ + offer: session.offer, + offerCandidates: session.offerCandidates, + }); + } + } catch (err) { + console.error('Error polling session:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /health + * Health check endpoint + */ + app.get('/health', (c) => { + return c.json({ status: 'ok', timestamp: Date.now() }); + }); + + return app; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ec0478f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,26 @@ +/** + * Application configuration + * Reads from environment variables with sensible defaults + */ +export interface Config { + port: number; + storageType: 'sqlite' | 'memory'; + storagePath: string; + sessionTimeout: number; + corsOrigins: string[]; +} + +/** + * Loads configuration from environment variables + */ +export function loadConfig(): Config { + return { + port: parseInt(process.env.PORT || '3000', 10), + storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory', + storagePath: process.env.STORAGE_PATH || ':memory:', + sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '300000', 10), + corsOrigins: process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map(o => o.trim()) + : ['*'], + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4fd9f4c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,59 @@ +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, + sessionTimeout: `${config.sessionTimeout}ms`, + corsOrigins: config.corsOrigins, + }); + + let storage: Storage; + + if (config.storageType === 'sqlite') { + storage = new SQLiteStorage(config.storagePath); + console.log('Using SQLite storage'); + } else { + throw new Error('Unsupported storage type'); + } + + const app = createApp(storage, { + sessionTimeout: config.sessionTimeout, + corsOrigins: config.corsOrigins, + }); + + const server = serve({ + fetch: app.fetch, + port: config.port, + }); + + console.log(`Server running on http://localhost:${config.port}`); + + process.on('SIGINT', async () => { + console.log('\nShutting down gracefully...'); + await storage.close(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + console.log('\nShutting down gracefully...'); + await storage.close(); + process.exit(0); + }); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/storage/kv.ts b/src/storage/kv.ts new file mode 100644 index 0000000..8890b5c --- /dev/null +++ b/src/storage/kv.ts @@ -0,0 +1,241 @@ +import { Storage, Session } from './types.ts'; + +/** + * Cloudflare KV storage adapter for session management + */ +export class KVStorage implements Storage { + private kv: KVNamespace; + + /** + * Creates a new KV storage instance + * @param kv Cloudflare KV namespace binding + */ + constructor(kv: KVNamespace) { + this.kv = kv; + } + + /** + * Generates a unique code using Web Crypto API + */ + private generateCode(): string { + return crypto.randomUUID(); + } + + /** + * Gets the key for storing a session + */ + private sessionKey(code: string): string { + return `session:${code}`; + } + + /** + * Gets the key for the topic index + */ + private topicIndexKey(origin: string, topic: string): string { + return `index:${origin}:${topic}`; + } + + async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise { + // Validate info length + if (info.length > 1024) { + throw new Error('Info string must be 1024 characters or less'); + } + + const code = this.generateCode(); + const createdAt = Date.now(); + + const session: Session = { + code, + origin, + topic, + info, + offer, + answer: undefined, + offerCandidates: [], + answerCandidates: [], + createdAt, + expiresAt, + }; + + // Calculate TTL in seconds for KV + const ttl = Math.max(60, Math.floor((expiresAt - createdAt) / 1000)); + + // Store the session + await this.kv.put( + this.sessionKey(code), + JSON.stringify(session), + { expirationTtl: ttl } + ); + + // Update the topic index + const indexKey = this.topicIndexKey(origin, topic); + const existingIndex = await this.kv.get(indexKey, 'json') as string[] | null; + const updatedIndex = existingIndex ? [...existingIndex, code] : [code]; + + // Set index TTL to slightly longer than session TTL to avoid race conditions + await this.kv.put( + indexKey, + JSON.stringify(updatedIndex), + { expirationTtl: ttl + 300 } + ); + + return code; + } + + async listSessionsByTopic(origin: string, topic: string): Promise { + const indexKey = this.topicIndexKey(origin, topic); + const codes = await this.kv.get(indexKey, 'json') as string[] | null; + + if (!codes || codes.length === 0) { + return []; + } + + // Fetch all sessions in parallel + const sessionPromises = codes.map(async (code) => { + const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null; + return sessionData; + }); + + const sessions = await Promise.all(sessionPromises); + + // Filter out expired or answered sessions, and null values + const now = Date.now(); + const validSessions = sessions.filter( + (session): session is Session => + session !== null && + session.expiresAt > now && + session.answer === undefined + ); + + // Sort by creation time (newest first) + return validSessions.sort((a, b) => b.createdAt - a.createdAt); + } + + async listTopics(origin: string, page: number, limit: number): Promise<{ + topics: Array<{ topic: string; count: number }>; + pagination: { + page: number; + limit: number; + total: number; + hasMore: boolean; + }; + }> { + // Ensure limit doesn't exceed 1000 + const safeLimit = Math.min(Math.max(1, limit), 1000); + const safePage = Math.max(1, page); + + const prefix = `index:${origin}:`; + const topicCounts = new Map(); + + // List all index keys for this origin + const list = await this.kv.list({ prefix }); + + // Process each topic index + for (const key of list.keys) { + // Extract topic from key: "index:{origin}:{topic}" + const topic = key.name.substring(prefix.length); + + // Get the session codes for this topic + const codes = await this.kv.get(key.name, 'json') as string[] | null; + + if (!codes || codes.length === 0) { + continue; + } + + // Fetch sessions to count only valid ones (unexpired and unanswered) + const sessionPromises = codes.map(async (code) => { + const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null; + return sessionData; + }); + + const sessions = await Promise.all(sessionPromises); + + // Count valid sessions + const now = Date.now(); + const validCount = sessions.filter( + (session) => + session !== null && + session.expiresAt > now && + session.answer === undefined + ).length; + + if (validCount > 0) { + topicCounts.set(topic, validCount); + } + } + + // Convert to array and sort by topic name + const allTopics = Array.from(topicCounts.entries()) + .map(([topic, count]) => ({ topic, count })) + .sort((a, b) => a.topic.localeCompare(b.topic)); + + // Apply pagination + const total = allTopics.length; + const offset = (safePage - 1) * safeLimit; + const topics = allTopics.slice(offset, offset + safeLimit); + + return { + topics, + pagination: { + page: safePage, + limit: safeLimit, + total, + hasMore: offset + topics.length < total, + }, + }; + } + + async getSession(code: string, origin: string): Promise { + const sessionData = await this.kv.get(this.sessionKey(code), 'json') as Session | null; + + if (!sessionData) { + return null; + } + + // Validate origin and expiration + if (sessionData.origin !== origin || sessionData.expiresAt <= Date.now()) { + return null; + } + + return sessionData; + } + + async updateSession(code: string, origin: string, update: Partial): Promise { + const current = await this.getSession(code, origin); + + if (!current) { + throw new Error('Session not found or origin mismatch'); + } + + // Merge updates + const updated: Session = { + ...current, + ...(update.answer !== undefined && { answer: update.answer }), + ...(update.offerCandidates !== undefined && { offerCandidates: update.offerCandidates }), + ...(update.answerCandidates !== undefined && { answerCandidates: update.answerCandidates }), + }; + + // Calculate remaining TTL + const ttl = Math.max(60, Math.floor((updated.expiresAt - Date.now()) / 1000)); + + // Update the session + await this.kv.put( + this.sessionKey(code), + JSON.stringify(updated), + { expirationTtl: ttl } + ); + } + + async deleteSession(code: string): Promise { + await this.kv.delete(this.sessionKey(code)); + } + + async cleanup(): Promise { + // KV automatically expires keys based on TTL + // No manual cleanup needed + } + + async close(): Promise { + // No connection to close for KV + } +} diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts new file mode 100644 index 0000000..e73dafb --- /dev/null +++ b/src/storage/sqlite.ts @@ -0,0 +1,258 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; +import { Storage, Session } from './types.ts'; + +/** + * SQLite storage adapter for session 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(); + this.startCleanupInterval(); + } + + /** + * Initializes database schema + */ + private initializeDatabase(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + code TEXT PRIMARY KEY, + origin TEXT NOT NULL, + topic TEXT NOT NULL, + info TEXT NOT NULL CHECK(length(info) <= 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 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); + `); + } + + /** + * Starts periodic cleanup of expired sessions + */ + private startCleanupInterval(): void { + // Run cleanup every minute + setInterval(() => { + this.cleanup().catch(err => { + console.error('Cleanup error:', err); + }); + }, 60000); + } + + /** + * Generates a unique code using UUID + */ + private generateCode(): string { + return randomUUID(); + } + + async createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise { + // Validate info length + if (info.length > 1024) { + throw new Error('Info string must be 1024 characters or less'); + } + + let code: string; + let attempts = 0; + const maxAttempts = 10; + + // Try to generate a unique code + do { + code = this.generateCode(); + attempts++; + + if (attempts > maxAttempts) { + throw new Error('Failed to generate unique session code'); + } + + try { + const stmt = this.db.prepare(` + INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt); + break; + } catch (err: any) { + // If unique constraint failed, try again + if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') { + continue; + } + throw err; + } + } while (true); + + return code; + } + + async listSessionsByTopic(origin: string, topic: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM sessions + WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL + ORDER BY created_at DESC + `); + + const rows = stmt.all(origin, topic, Date.now()) as any[]; + + return rows.map(row => ({ + code: row.code, + origin: row.origin, + topic: row.topic, + info: row.info, + offer: row.offer, + answer: row.answer || undefined, + offerCandidates: JSON.parse(row.offer_candidates), + answerCandidates: JSON.parse(row.answer_candidates), + createdAt: row.created_at, + expiresAt: row.expires_at, + })); + } + + async listTopics(origin: string, page: number, limit: number): Promise<{ + topics: Array<{ topic: string; count: number }>; + pagination: { + page: number; + limit: number; + total: number; + hasMore: boolean; + }; + }> { + // Ensure limit doesn't exceed 1000 + const safeLimit = Math.min(Math.max(1, limit), 1000); + const safePage = Math.max(1, page); + const offset = (safePage - 1) * safeLimit; + + // Get total count of topics + const countStmt = this.db.prepare(` + SELECT COUNT(DISTINCT topic) as total + FROM sessions + WHERE origin = ? AND expires_at > ? AND answer IS NULL + `); + const { total } = countStmt.get(origin, Date.now()) as any; + + // Get paginated topics + const stmt = this.db.prepare(` + SELECT topic, COUNT(*) as count + FROM sessions + WHERE origin = ? AND expires_at > ? AND answer IS NULL + GROUP BY topic + ORDER BY topic ASC + LIMIT ? OFFSET ? + `); + + const rows = stmt.all(origin, Date.now(), safeLimit, offset) as any[]; + + const topics = rows.map(row => ({ + topic: row.topic, + count: row.count, + })); + + return { + topics, + pagination: { + page: safePage, + limit: safeLimit, + total, + hasMore: offset + topics.length < total, + }, + }; + } + + async getSession(code: string, origin: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM sessions WHERE code = ? AND origin = ? AND expires_at > ? + `); + + const row = stmt.get(code, origin, Date.now()) as any; + + if (!row) { + return null; + } + + return { + code: row.code, + origin: row.origin, + topic: row.topic, + info: row.info, + offer: row.offer, + answer: row.answer || undefined, + offerCandidates: JSON.parse(row.offer_candidates), + answerCandidates: JSON.parse(row.answer_candidates), + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + + async updateSession(code: string, origin: string, update: Partial): Promise { + const current = await this.getSession(code, origin); + + if (!current) { + throw new Error('Session not found or origin mismatch'); + } + + const updates: string[] = []; + const values: any[] = []; + + if (update.answer !== undefined) { + updates.push('answer = ?'); + values.push(update.answer); + } + + if (update.offerCandidates !== undefined) { + updates.push('offer_candidates = ?'); + values.push(JSON.stringify(update.offerCandidates)); + } + + if (update.answerCandidates !== undefined) { + updates.push('answer_candidates = ?'); + values.push(JSON.stringify(update.answerCandidates)); + } + + if (updates.length === 0) { + return; + } + + values.push(code); + values.push(origin); + + const stmt = this.db.prepare(` + UPDATE sessions SET ${updates.join(', ')} WHERE code = ? AND origin = ? + `); + + stmt.run(...values); + } + + async deleteSession(code: string): Promise { + const stmt = this.db.prepare('DELETE FROM sessions WHERE code = ?'); + stmt.run(code); + } + + async cleanup(): Promise { + const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?'); + const result = stmt.run(Date.now()); + + if (result.changes > 0) { + console.log(`Cleaned up ${result.changes} expired session(s)`); + } + } + + async close(): Promise { + this.db.close(); + } +} diff --git a/src/storage/types.ts b/src/storage/types.ts new file mode 100644 index 0000000..a420e0f --- /dev/null +++ b/src/storage/types.ts @@ -0,0 +1,90 @@ +/** + * Represents a WebRTC signaling session + */ +export interface Session { + code: string; + origin: string; + topic: string; + info: string; + offer: string; + answer?: string; + offerCandidates: string[]; + answerCandidates: string[]; + createdAt: number; + expiresAt: number; +} + +/** + * Storage interface for session management + * Implementations can use different backends (SQLite, Redis, Memory, etc.) + */ +export interface Storage { + /** + * Creates a new session with the given offer + * @param origin The Origin header from the request + * @param topic The topic to post the offer to + * @param info User info string (max 1024 chars) + * @param offer The WebRTC SDP offer message + * @param expiresAt Unix timestamp when the session should expire + * @returns The unique session code + */ + createSession(origin: string, topic: string, info: string, offer: string, expiresAt: number): Promise; + + /** + * Lists all unanswered sessions for a given origin and topic + * @param origin The Origin header from the request + * @param topic The topic to list offers for + * @returns Array of sessions that haven't been answered yet + */ + listSessionsByTopic(origin: string, topic: string): Promise; + + /** + * Lists all topics for a given origin with their session counts + * @param origin The Origin header from the request + * @param page Page number (starting from 1) + * @param limit Number of results per page (max 1000) + * @returns Object with topics array and pagination metadata + */ + listTopics(origin: string, page: number, limit: number): Promise<{ + topics: Array<{ topic: string; count: number }>; + pagination: { + page: number; + limit: number; + total: number; + hasMore: boolean; + }; + }>; + + /** + * Retrieves a session by its code + * @param code The session code + * @param origin The Origin header from the request (for validation) + * @returns The session if found, null otherwise + */ + getSession(code: string, origin: string): Promise; + + /** + * Updates an existing session with new data + * @param code The session code + * @param origin The Origin header from the request (for validation) + * @param update Partial session data to update + */ + updateSession(code: string, origin: string, update: Partial): Promise; + + /** + * Deletes a session + * @param code The session code + */ + deleteSession(code: string): Promise; + + /** + * Removes expired sessions + * Should be called periodically to clean up old data + */ + cleanup(): Promise; + + /** + * Closes the storage connection and releases resources + */ + close(): Promise; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..3696545 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,39 @@ +import { createApp } from './app.ts'; +import { KVStorage } from './storage/kv.ts'; + +/** + * Cloudflare Workers environment bindings + */ +export interface Env { + SESSIONS: KVNamespace; + SESSION_TIMEOUT?: string; + CORS_ORIGINS?: string; +} + +/** + * Cloudflare Workers fetch handler + */ +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Initialize KV storage + const storage = new KVStorage(env.SESSIONS); + + // Parse configuration + const sessionTimeout = env.SESSION_TIMEOUT + ? parseInt(env.SESSION_TIMEOUT, 10) + : 300000; // 5 minutes default + + const corsOrigins = env.CORS_ORIGINS + ? env.CORS_ORIGINS.split(',').map(o => o.trim()) + : ['*']; + + // Create Hono app + const app = createApp(storage, { + sessionTimeout, + corsOrigins, + }); + + // Handle request + return app.fetch(request, env, ctx); + }, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b5a6d23 --- /dev/null +++ b/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/wrangler.toml.example b/wrangler.toml.example new file mode 100644 index 0000000..e32eb1e --- /dev/null +++ b/wrangler.toml.example @@ -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