| server | ||
| .gitignore | 40 B | |
| compose.yml | 614 B | |
| Containerfile | 811 B | |
| entrypoint.sh | 375 B | |
| index.html | 275.9 KB | |
| README.md | 7.5 KB |
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:
- 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.
- 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/<code>/offer POST /room/<code>/answer
GET /room/<code>/offer GET /room/<code>/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 8000and openhttp://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
example.com {
encode zstd gzip
root * /var/www/webrtc
@signaling path /room/* /health
reverse_proxy @signaling 127.0.0.1:8080
file_server
}
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
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 URLfield in the configure screen can be left at its default (location.origin). http://localhostis 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".