commit 82c0e8b065447cb715aefe57c9757b1315bcb7ec Author: Bas van den Aakster Date: Sun Nov 2 14:32:25 2025 +0100 Initial commit: Rondevu signaling server 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 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