This backend offers WebRTC signaling via WebSockets and a small REST API backed by SQLite to coordinate pairing using short join codes. Media flows peer-to-peer; the server just forwards s## Security Recommendations
- Use WSS (TLS) for signaling in production.
- TURN with long-term credentials; avoid anonymous no-auth in production.
- Limit TURN to required ports and IPs; monitor logs for abuse.
- Consider rate limiting join attempts and message sizes on signaling.
- Don't trust client SDP contents; validate JSON shapes where possible.
- Ephemeral Device IDs: Clients should use unique ephemeral IDs per session for privacy; the purge API supports batch deletion of multiple IDs.
- Automatic Cleanup: Implement automatic expiration of old rooms and pendings to prevent database bloat.
---messages. A separate TURN server can relay media if direct connectivity fails.
Quick links
- New DB schema (pendings + rooms)
- New REST API: create, accept, check, get, delete
- WebSocket message types
- Local dev and Docker
- TURN and reverse proxy notes
We split pairing into two stages:
- pendings: created by client1 with a short join code (joinid), an expiration in seconds (expiresInSeconds), and client1 id. client2 is empty until accepted.
- rooms: created when client2 accepts. Contains the final roomid and both client ids.
Schema (scripts/init-db.sql)
- pendings: joinid (PK, TEXT NOT NULL), client1 (TEXT NOT NULL), exp (DATETIME NOT NULL, UTC ISO-8601), client2 (TEXT NULL)
- rooms: roomid (PK, TEXT NOT NULL), client1 (TEXT NOT NULL), client2 (TEXT NOT NULL)
Indices
- pendings: by client1, by exp
- rooms: by client1, by client2
Expiration & Cleanup
- Server-side calculation: Clients send
expiresInSeconds(duration), server calculates exactexptimestamp - Automatic cleanup: SQL triggers delete expired pendings on INSERT/UPDATE
- Atomic operations: Cleanup happens in same transaction as queries (no race conditions)
- Proper datetime comparison: Uses
datetime()function for accurate timestamp comparison
Base URL: http://host:PORT Content-Type: application/json
- POST
/api/rooms - Body:
{ joinid: string, expiresInSeconds: number, client1: string }joinid: 6-digit join codeexpiresInSeconds: Duration in seconds (1-86400, max 24 hours)client1: Device UUID (use ephemeral IDs for privacy)
- Success:
201 { ok: true, exp: "2025-10-07T10:00:00.000Z" }- Server calculates and returns the exact expiry timestamp
- Errors:
400 { error: "missing_field" }- Missing required parameter400 { error: "invalid_expiresInSeconds", details: "must be 1-86400 seconds (1 sec to 24 hours)" }500- Server error
Common duration values:
- 1 minute =
60 - 5 minutes =
300 - 10 minutes =
600 - 1 hour =
3600 - 12 hours =
43200 - 24 hours =
86400
- POST
/api/rooms/accept - Body:
{ joinid: string, client2: string } - Success:
200 { roomid: string } - Errors:
400- Missing fields404 { error: "not_found_or_expired" }- Code doesn't exist or expired409 { error: "conflict" }- Code already accepted500- Server error
- POST
/api/rooms/check - Body:
{ joinid: string, client1: string } - Success:
200 { roomid: string }- Room created, pending deleted server-side - Pending:
204 No Content- Still waiting for client2 - Errors:
400- Missing fields404 { error: "not_found_or_expired" }- Code doesn't exist or expired500- Server error
- GET
/api/rooms?roomid=... - Success:
200 { roomid, client1, client2 } - Errors:
400(missing roomid),404,500
- DELETE
/api/rooms - Body:
{ roomid: string } - Success:
200 - Errors:
400(missing roomid),500
- POST
/api/user/purge - Body:
{ deviceIds: string[] }OR{ deviceId: string }(legacy) - Success:
200 { success: true, deviceIdCount: number, roomsDeleted: number, pendingsDeleted: number } - Errors:
400- Missing deviceIds/deviceId, empty array, or invalid IDs500- Server error
- Notes:
- Preferred format uses
deviceIdsarray for batch deletion - Legacy single
deviceIdstring format still supported - Deletes all rooms and pendings where client1 or client2 matches any provided device ID
- Preferred format uses
Creating and joining a room:
- Client1 →
POST /api/rooms { joinid: "123456", expiresInSeconds: 300, client1: "device-uuid-1" }→201 { ok: true, exp: "2025-10-07T10:05:00.000Z" } - Client2 →
POST /api/rooms/accept { joinid: "123456", client2: "device-uuid-2" }→200 { roomid: "abc123..." } - Client1 polls →
POST /api/rooms/check { joinid: "123456", client1: "device-uuid-1" }→200 { roomid: "abc123..." } - Both clients connect via WebSocket using
roomid
Batch purge:
- Client has ephemeral IDs:
["id1", "id2", "id3"] - Client →
POST /api/user/purge { deviceIds: ["id1", "id2", "id3"] }→200 { success: true, deviceIdCount: 3, roomsDeleted: 5, pendingsDeleted: 2 }
Inbound from client → server
- join_room: { type: "join_room", roomId: string }
- leave_room: { type: "leave_room" }
- webrtc_offer: { type: "webrtc_offer", sdp: string }
- webrtc_answer: { type: "webrtc_answer", sdp: string }
- ice_candidate: { type: "ice_candidate", candidate: { candidate, sdpMLineIndex, sdpMid } }
- ping: { type: "ping" }
Outbound from server → client
- connected, room_joined, room_ready, webrtc_offer, webrtc_answer, ice_candidate, peer_left, peer_disconnected, left_room, room_full, error
Notes
- WebSocket roomId should be the roomid returned by the REST accept/check phase.
Prereqs
- Node.js 18+ (tested on 22.x)
- sqlite3 CLI available on PATH
Install and init DB
npm install
npm run db:initRun server
USE_SQLITE=1 npm run devEnvironment
PORT=8080
# optional; defaults to ./data/chat.db
SQLITE_DB_PATH=./data/chat.db- Docker Compose included to run signaling and coturn.
- Put a TLS reverse proxy (nginx, Caddy) in front for WSS.
- Ensure TURN (coturn) ports are open if you need relay: 3478 UDP/TCP, 5349 TCP, plus UDP relay range.
- GET / returns a simple health message.
- GET /rooms shows in-memory WS rooms (for debugging only).
- If REST calls return 404 on /check, the join code likely expired or was never created.
Prerequisites
- Docker and Docker Compose installed.
- Optional:
.envfile at repo root with the server port, e.g.PORT=8080.
Start services
docker compose up --buildRun in background
docker compose up -d --buildStop and remove
docker compose downThe signaling service listens on http://localhost:${PORT} and upgrades to WebSocket for /.
For production with TLS termination (e.g., nginx):
- Ensure HTTP/1.1 upgrade headers are forwarded:
proxy_http_version 1.1proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";
- Extend timeouts to avoid idle disconnects:
proxy_read_timeout 3600; proxy_send_timeout 3600; keepalive_timeout 3600;
- Point
wss://your-domainto the signaling service upstream.
Why TURN?
- NAT traversal often works with STUN (host/srflx candidates), but in carrier-grade NATs or strict firewalls, only TURN relay succeeds. TURN relays media/data between peers when direct P2P isn’t possible.
Configuration (turn-server/turnserver.conf)
- Typical important settings:
lt-cred-mechwithuser=<username>:<password>for long-term credentials.realm=<your-domain>listening-port=3478(UDP/TCP)tls-listening-port=5349(for TURN over TLS, a.k.a.turns:)external-ip=<public-ip-or-dns>min-port=49152andmax-port=65535for relay allocations
Ports to open on the host/firewall
- 3478/udp (TURN)
- 3478/tcp (TURN over TCP)
- 5349/tcp (TURN over TLS)
- 49152–65535/udp (relay ports range)
Clients should be configured with a mix of STUN/TURN servers, e.g.:
stun:stun.l.google.com:19302turn:your-domain:3478(UDP)turn:your-domain:3478?transport=tcpturns:your-domain:5349(TLS)- Optionally, a public TURN (e.g., openrelay.metered.ca) as fallback.
Operational notes
- TURN allocates relayed addresses per session, so bandwidth costs can apply.
- Prefer UDP, but keep TCP/TLS variants for networks blocking UDP.
- Use WSS (TLS) for signaling in production.
- TURN with long-term credentials; avoid anonymous no-auth in production.
- Limit TURN to required ports and IPs; monitor logs for abuse.
- Consider rate limiting join attempts and message sizes on signaling.
- Don’t trust client SDP contents; validate JSON shapes where possible.
Basics
- Health check:
GET /should log "Health check" and return a short page. - Room introspection:
GET /roomsreturns list of rooms with counts.
Common symptoms
- Unknown message type:
- Ensure clients send
typevalues as described; keep server up to date.
- Ensure clients send
- Can’t rejoin same room after leave:
- Verify server has
leave_roomhandler (present inindex.js). - Check
/roomsto ensure old room cleaned up. If not, the connection may not have closed—ensure the client sendsleave_roomor the socket closes.
- Verify server has
- P2P works only on WiFi but not across networks:
- Confirm TURN ports open and reachable; check you see
typ relaycandidates in client logs. - Add TCP/TLS TURN URIs for networks that block UDP.
- Confirm TURN ports open and reachable; check you see
- WebSocket drops after idle:
- Ensure proxy timeouts are generous and clients send pings.
Verifying TURN
- From clients, log ICE candidates and look for
typ relay. - In coturn logs, you should see allocations when a session relays.
Node version: 22.19.0
Environment
PORT=8080
Run signaling only (without Docker)
npm install
npm run db:init
npm run devRun both via Docker Compose
docker compose up --buildAccess
- Signaling: http://localhost:8080 (upgrades to WS)
- TURN: provided by coturn container (host networking if configured)
This project uses the sqlite3 CLI (no native Node addons) to initialize and access a simple rooms table.
Defaults
- DB file:
./data/chat.db(override withSQLITE_DB_PATH=/custom/path.db) - Schema file:
./scripts/init-db.sql
Initialize the DB
npm install
npm run db:initEnable API and DB hooks
USE_SQLITE=1 npm run devEnvironment
# optional; defaults to ./data/chat.db
export SQLITE_DB_PATH=$PWD/data/chat.dbNotes
- Docker Compose installs
sqlitein the container and runsnpm run db:initautomatically. - If
USE_SQLITEis not set, the Rooms API is disabled.