server
.gitignore40 B
compose.yml614 B
Containerfile811 B
entrypoint.sh375 B
index.html275.9 KB
README.md7.5 KB
READMERaw

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/<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 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

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 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".