A self-hosted group video-conferencing app — a lightweight Jitsi replacement, tied to the SwiftIRC network. One Go binary runs the whole thing: a Pion-based SFU (selective forwarding unit), the WebSocket signaling, an HTTP API, and the embedded vanilla-JS browser client. No database, no TURN, no build step for the client.
Jitsi's stack (Prosody, Jicofo, JVB, nginx) is heavy to operate, resource hungry, and hard to customize. This is a codebase small enough to understand end-to-end (~3.7k lines of non-test Go plus a no-build-step JS client), that we own and can wire natively into SwiftIRC.
- Multi-party video/audio rooms (rooms of 6–15+, dozens of concurrent users on a single node).
- Screen sharing (its own tile, separate from your camera).
- In-room text chat with 200-message replay for late joiners.
- Op moderation: kick, ban, force-mute (a re-enableable nudge), stop a screenshare, and per-room password lock — driven by the participant's IRC op status, via signed tokens minted by the Anope module.
- Optional per-room password and identified-users-only mode.
- In-room polls: an op posts a question, everyone votes, tallies update live.
- Mic noise suppression (an AudioWorklet, on by default, opt-out).
- Background blur and virtual backgrounds (MediaPipe selfie segmentation, run
entirely in the browser), with an automatic fall back to no effect on a device
that cannot sustain a usable frame rate. The picker offers two rows: Effects
(blur and procedurally-painted washes, drawn in code) and Scenes (photographic
WebP backgrounds, discovered at build time from
internal/web/assets/img/bg/— drop one in and rebuild to add it; the Scenes row is omitted entirely if there are none). Requires WebGL — MediaPipe's vision graph is GL-based regardless of whether inference runs on the GPU or the CPU, so with hardware acceleration disabled the picker says so and offers only "None". - Low-bandwidth mode: stop downloading all inbound video while keeping audio. Per-user and purely local — it changes only what you receive.
- Per-participant local volume, a mirrored self-view, and a shared countdown sound.
- Self-healing clients: reconnect on drop, ICE-restart on media failure, and — importantly — a kicked/banned client stops and does not rejoin.
┌──────────────────────────── one Go binary ───────────────────────────┐
browser ── wss ───────┤ static files (embed) HTTP API WS signaling Pion SFU (UDP RTP) │
(vanilla JS ES modules)│ internal/web /api/... /ws/{room} internal/sfu │
│ internal/server (Hub) internal/room │
└───────────────────────────────────────────────────────────────────────┘
media (RTP/UDP) ─────────────────────────────────────────► directly to the SFU's public IP
(NOT proxied by nginx — see docs/DEPLOY.md)
internal/config— flags/env config.internal/token— HMAC identity tokens (minted by the Anope module, verified here). Cross-implementation test vectors ininternal/token/testdata/vectors.jsonare what keep the two implementations byte-identical.internal/signal— the JSON WebSocket wire protocol.internal/room— pure room state: join rules, roster, chat ring, moderation, countdown. No I/O, no Pion.internal/sfu— the media plane (Pionwebrtc/v4): one PeerConnection per participant, forwarding each published track (VP8/Opus) to every other participant; perfect-negotiation with the server as the impolite peer.internal/server— theHub: WebSocket join flow, signaling dispatch, HTTP API, static serving.internal/web— the embedded browser client (assets/), withnode --testunit tests for its pure logic (test/). Two vendored payloads live here and account for most of the binary: the MediaPipe segmentation runtime (assets/vendor/mediapipe/, stored gzipped) and whichever background scenes are inassets/img/bg/(~250 KB for the two in the repository, ~470 KB with the author's full local set). Both carry a README recording source, licence and checksums; readassets/img/bg/README.mdbefore redistributing a build. A binary from a clean clone is ~23.0 MiB, against 18,756,525 bytes before MediaPipe was vendored.cmd/coyote— the entrypoint.anope/m_webrtc_chat— the Anope 2.1 services module (C++):!vc/!chatfantasy commands,VC SET, HMAC token minting, and the/api/provisionand/api/invitecalls. Itscore/is header-only and host-testable with no Anope SDK —make -C anope/m_webrtc_chat/tests test.
Requires Go 1.26+.
go build -o coyote ./cmd/coyote
./coyote -addr :8080 # local dev (http; getUserMedia works on localhost)Open http://localhost:8080/<room> in two browser tabs to try a call.
For a real deployment (TLS via a reverse proxy, the media UDP port range, and
-public-ip), see docs/DEPLOY.md — in particular, media
is UDP sent directly to the SFU and does not flow through nginx, so
-public-ip and an open UDP range are required or remote video will be black.
| Flag | Env | Default | Purpose |
|---|---|---|---|
-addr |
WVC_ADDR |
:8080 |
HTTP listen address |
-public-ip |
WVC_PUBLIC_IP |
(empty) | Public IP advertised in ICE candidates — required for remote media |
-udp-min / -udp-max |
WVC_UDP_MIN / _MAX |
50000 / 50199 |
Media UDP port range (open in the firewall) |
-secret |
WVC_SECRET |
(empty) | Shared HMAC secret for tokens + /api/provision (empty = ad-hoc rooms only) |
-adhoc |
WVC_ADHOC |
true |
Allow non-IRC rooms created on demand |
-trust-proxy |
WVC_TRUST_PROXY |
false |
Trust X-Forwarded-For (enable only behind a trusted proxy) |
-tls-cert / -tls-key |
WVC_TLS_CERT / _KEY |
(empty) | Optional built-in TLS |
go test -race ./... # Go: unit + Pion synthetic-client media integration
go vet ./...
nvm use && node --test internal/web/test/ # client pure-logic unit tests (Node 22; .nvmrc)
make -C anope/m_webrtc_chat/tests test # Anope module core (C++; no Anope SDK needed)internal/sfu's TestThreeClientsFullMesh is a known flake under CPU load
(roughly 8 failures in 20 runs on a loaded machine, none when idle). It is a
harness-timing problem, not a product one; re-run the package before investigating.
The browser client's DOM/media/WebRTC surface has no automated coverage — there is
no DOM harness, and adding one (jsdom) would mean the project's first npm
dependency and a node_modules, which the no-build-step client deliberately avoids.
It is verified by hand against MANUAL-TEST.md in ≥2 real
browsers, including glare convergence, which the in-process Pion tests cannot
exercise (Pion has no SDP rollback). Treat that checklist as load-bearing: the bugs
that have reached production in this codebase have overwhelmingly lived in exactly
that untested layer.
- Server core — config, tokens, protocol, room state + moderation, registry/GC, WebSocket hub, HTTP API, lifecycle. Done.
- SFU media plane — VP8/Opus forwarding, fan-out, PLI, tracks metadata, perfect-negotiation glare handling. Done.
- Browser client — pre-join, tile grid, chat, polls, moderation controls, screenshare, noise suppression, background effects, reliability wiring. Done.
- Anope 2.1 module —
!vc/!chatfantasy commands,VC SET, HMAC token minting (against the shared test vectors),/api/provisionand short#i=invite links via/api/invite. Done and deployed.
Design and implementation plans live in docs/superpowers/.
MIT — for this project's own source.
The repository also carries third-party content that MIT does not cover, and
//go:embed all:assets puts all of it inside every binary — see
THIRD-PARTY-NOTICES.md. In short: the vendored
MediaPipe runtime is Apache-2.0, and the noise-suppression worklet bundles RNNoise
and core-js.
Background scenes are discovered from what a build embedded, not hard-coded, so
what ships is whatever is in internal/web/assets/img/bg/ at build time. Only the two
JWST images — carina.webp and pillars.webp (NASA/ESA/CSA/STScI) — are in the
repository; anything else is git-ignored. Drop your own .webp in there and rebuild
to add one.