# WebRTC Tool A single-page tool for direct peer-to-peer audio, video, chat, and file transfer between browsers, using WebRTC for the media/data path. The client is one self-contained `index.html` with no build step. An optional signaling server (~600 lines of C, POSIX `poll`, no dependencies) lets peers connect by sharing a short room code instead of pasting SDP blobs. ## What it does - **Multi-peer calls.** Microphone, camera, screen share. Add participants one at a time; each is an independent pairwise connection, and they tile into a conference grid. Standard WebRTC. - **Chat** over a text data channel, fanned out to every connected peer. - **File transfer** over a separate data channel with backpressure, cancellable mid-upload, sent to the peers you select. The receiver gets an accept/deny prompt; on accept the file is streamed straight to a chosen location on disk (via the File System Access API, with an in-memory download fallback for browsers that lack it, e.g. Firefox) - **Manual signaling.** Generate an offer, copy/paste it to the other peer, paste back their answer. No backend required. - **Auto signaling.** Optional: both peers enter the same room code and the signaling server relays the offer/answer for them. Media itself still flows peer-to-peer. - **Loopback mode.** Run both peers in the same tab for testing. - **Stats** (RTP, codec, bandwidth) and an in-page console. ## How it works WebRTC requires each pair of peers to exchange Session Description Protocol (SDP) blobs (offer/answer) before media can flow. After that exchange, the connection is peer-to-peer; the signaling channel is no longer used. A call with more than two participants is simply several of these pairwise connections, each set up independently. This tool offers two ways to do that exchange: 1. **Manual.** The initiator generates an offer (a few KB of text), sends it to the joiner by any means (email, chat, etc.). The joiner pastes it, generates an answer, sends it back, and the initiator pastes it. No server involved. 2. **Auto.** Both peers enter the same room code into the signaling server's UI. The server stores the offer briefly, hands it to the other peer when they ask, and is forgotten as soon as the answer has been delivered. Rooms expire after 5 minutes of inactivity. Each room carries a single offer/answer pair — one pairwise link — so a mesh of more than two peers is built one connection (one room code) at a time. The signaling server understands five endpoints: ``` POST /room//offer POST /room//answer GET /room//offer GET /room//answer GET /health ``` Long-polling: a `GET` that arrives before the matching blob exists is parked for up to 10 s and answered with `204 No Content` on timeout; the client retries. As soon as the matching `POST` arrives, the parked request is woken with `200` and the SDP body. After the answer has been delivered to the initiator, the room is dropped. ## Usage ### Standalone (no backend) The full feature set works without the backend, using manual signaling. Each peer just needs to load `index.html`; they don't need to load it from the same place. Options: - Send the file to the other peer and have them open it from disk (`file://…`). - Host it on any static web server (a personal site, GitHub Pages, Netlify, S3, etc.) and share the URL. - For local testing on one machine, serve it on loopback: `python3 -m http.server 8000` and open `http://localhost:8000/`. Then both peers pick **Manual** mode in the configure screen, the initiator generates the offer and sends the blob to the joiner (email, chat, etc.), and the joiner sends the answer back the same way. For mic/camera/screen-share to work, the page must be loaded from a secure context: either `https://…`, `http://localhost`, or `file://` (some browsers, with limits). On plain `http://` to a remote host, browsers will refuse to grant media access. ### With the signaling backend The backend is needed only if you want auto signaling (peers exchange SDPs via a room code rather than copy-paste). It does **not** see or proxy media — that's still peer-to-peer. ``` podman compose up -d # builds the image and starts the server on :8080 # or: docker compose up -d ``` After startup, `./static/index.html` appears on the host (copied out of the image). Point your own web server at `./static` to serve the page. Or just build and run the binary directly: ``` cc -O2 -Wall -Wextra -Werror server/signal.c -o signal ./signal 8080 ``` ### Loopback (testing) In the configure screen, pick **Loopback** and click Continue. Both peers run in the same tab over a local RTCPeerConnection pair. Useful for trying chat/file transfer/codec settings without a second device. ## Self-hosting at a single URL For a production setup with both the HTML and the signaling server on the same origin (no CORS, no `Server URL` field to fill in), reverse- proxy `/room/*` and `/health` to the backend and serve everything else as static files. The backend binds to `127.0.0.1:8080` (or whatever you mapped in `compose.yml`). Configs below assume the static files live at `/var/www/webrtc` and the backend listens on `127.0.0.1:8080`. ### Caddy ```caddy example.com { encode zstd gzip root * /var/www/webrtc @signaling path /room/* /health reverse_proxy @signaling 127.0.0.1:8080 file_server } ``` ### nginx ```nginx server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; root /var/www/webrtc; index index.html; # Signaling backend: long-polled, must disable response buffering # so the 204/200 reaches the browser as soon as the server writes it. location ~ ^/(room/|health$) { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_buffering off; proxy_read_timeout 30s; } location / { try_files $uri $uri/ =404; } } ``` ### lighttpd ```lighttpd server.modules += ( "mod_proxy" ) server.document-root = "/var/www/webrtc" index-file.names = ( "index.html" ) $HTTP["url"] =~ "^/(room/|health$)" { proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => 8080 )) ) proxy.header = ( "upgrade" => "disable" ) } ``` ## Configuration notes - **HTTPS is required for production.** Browsers gate microphone, camera, and screen-share behind a secure context. The page works in receive-only mode without media access, but most users will want at least one side to publish. - **Same-origin removes the CORS path entirely.** When the HTML and signaling are served from the same scheme+host+port, the browser doesn't issue preflights and the `Server URL` field in the configure screen can be left at its default (`location.origin`). - **`http://localhost` is treated as secure** by Chromium and Firefox, so local development with mic/cam works without certificates. - **Server capacity is hard-coded in `server/signal.c`:** 1024 rooms. Rooms expire after 5 minutes of inactivity and are also deleted immediately once the answer reaches the initiator; a GC sweep runs every 60 s. - **ICE servers.** The client defaults to a public STUN server and lets you add your own STUN/TURN entries in the configure screen. STUN is enough for most networks, but two peers behind symmetric NATs won't connect without a **TURN** relay — add one if connections stall in "checking".