quickchat is an ephemeral chat app: disposable rooms with LiveKit voice and video, plus chat and file transfer that go peer to peer over WebRTC data channels. The server handles room metadata, presence and signaling only. One Go binary serves everything. License: 0BSD. Copyright Quad4 Software.
- Go 1.27 backend: chi router, coder/websocket for presence+signaling, livekit server-sdk-go for token minting, modernc.org/sqlite for room metadata, openapi.yaml embedded and served at /api/openapi.yaml
- React 19 + Vite 8 + TypeScript 6 (strict, exactOptionalPropertyTypes)
- Tailwind CSS 4 via @tailwindcss/vite, void theme tokens in web/src/app.css
- @livekit/components-react primitives (no components-styles, tiles are hand-styled), wouter for routing, lucide-react icons
- @scalar/api-reference-react for /docs, vite-plugin-pwa + workbox for the service worker and manifest
- pnpm 12, pinned by packageManager. Install is hardened in web/pnpm-workspace.yaml: minimumReleaseAge 7 days, strictDepBuilds, blockExoticSubdeps
make build # pnpm install + vite build + go build to bin/quickchat
make dev # go run the backend on :8080
make test # go tests incl race + vitest
make test-e2e # playwright + axe, builds web then runs the go binary
make check # gofmt, vet, golangci-lint, eslint, prettier, tsc
pnpm -C web dev # vite dev server, proxies /api and /ws to :8080
pnpm -C web lhci # lighthouse, needs CHROME_PATH on some systems
docker build -t quickchat:local .
docker compose up -d # needs QUICKCHAT_DOMAIN, ACME_EMAIL, LIVEKIT_*
- Dockerfile: 3-stage (node pnpm web build, go build, distroless nonroot
uid 65532). All base images pinned by digest. Healthcheck is the
quickchat healthchecksubcommand, no shell needed. - docker-compose.yml: app + bundled livekit + traefik, read-only, cap_drop ALL, tmpfs /tmp. LiveKit signaling is proxied by the app at /rtc so only one domain is needed; media still wants 7881/tcp and 7882/udp open.
- docker-compose.coolify.yml: single service, Coolify injects proxy labels. Set Domains to https://host:8080 on the app service only.
- Publishing lives in .github/workflows/docker.yml: ghcr push, zstd compression, cosign keyless sign, SBOM and OpenVEX attestations.
- Releases: .goreleaser.yaml builds the six platform archives on v* tags. Its before hooks run the web build so dist/ is embedded. The release workflow adds SLSA3 provenance via the generic generator, cosign sign-blob on checksums.txt, and actions/attest-build-provenance.
- Desktop: .goreleaser-desktop.yaml ships cmd/desktop. Linux and darwin link system webviews through cgo so each target builds on a native runner and skip-gates the others via DESKTOP_TARGET. Windows is cgo-free and covers amd64 and arm64 on one runner. The release job merges the partial checksums.txt files so the in-app updater can verify quickchat-desktop_* archives against the same signed file.
cmd/quickchat/ thin main
cmd/desktop/ wails v3 shell, runs the server in-process on
loopback and loads it in a webview window.
LIVEKIT_* env enables media, QUICKCHAT_ADDR
exposes hosted rooms to the lan. The wails
updater pulls quickchat-desktop_* assets from
github releases on a timer.
internal/config/ env-only configuration
internal/store/ sqlite room metadata
internal/rooms/ room id generation and TTL sweep
internal/hub/ websocket presence + webrtc signaling relay
internal/lktoken/ livekit access token minting
internal/server/ chi routes, embedded SPA, openapi.yaml, /rtc
reverse proxy to a bundled livekit
(LIVEKIT_INTERNAL_URL); in that mode the token
endpoint advertises this server's own origin
and the csp allows same-origin ws/wss
web/ React app, embed.go exports web.Dist (embeds dist/)
web/src/pages/ HomePage, RoomPage, DemoPage, DocsPage (scalar, lazy)
web/src/room/ Stage (livekit av), DemoStage, ChatPane, MessageRow,
DebugPanel (rtc stats overlay)
web/src/lib/signal.ts ws client: presence + signal relay only,
reconnect state machine + offline detection
web/src/lib/mesh.ts webrtc datachannel mesh: chat, typing, files
web/src/lib/useViewport.ts visualViewport tracking for the ios
software keyboard (100dvh does not shrink)
web/src/lib/stats.ts debug panel collector registry + hooks
web/src/lib/StatsProvider.tsx stats registry provider component
web/src/lib/pwa.ts service worker registration + update flow
web/src/lib/e2ee.ts url-fragment media keys + e2ee support check
web/src/lib/noisefilter.ts qc-nsup pref, mic constraints and the
enable/disable dance for ml noise suppression
web/src/lib/dfnproc.ts DeepFilterNet3 livekit TrackProcessor; lazy
loaded so the 22 mb wasm+model never enter the
initial or Stage chunks
web/src/lib/recovery.ts stale-chunk detection + guarded one-shot reload
web/src/components/ Mark, ErrorBoundary (page/panel), ErrorView
web/e2e/ playwright + axe specs against the real binary,
live.test.ts needs LIVEKIT_* env to run
Chat has two transports. When a livekit grant exists, all chat frames ride the livekit room data channel (topic q, reliable) via lib/lksession.ts, since direct p2p links fail on some nat setups. Without livekit, lib/mesh.ts uses the same frames over WebRTC datachannels. Both validate through lib/wire.ts.
- ws frames (server): welcome {self, peers}, peer_joined, peer_left, signal {from, data}
- ws frames (client): signal {to, data} where data is sdp or ice
- 'chat' datachannel or livekit data (json): {t:chat, id, body?, file?, ts, reply?}, {t:typing, on}, {t:delete, id}, {t:react, id, emoji, on}, {t:present, id, on}, {t:sound, name}, {t:rename, name}, {t:avatar, data}. reply is a denormalized quote {id, name, body?|file?} since nothing is stored. present toggles the shared deck viewer on the pdf message id. sound logs a soundboard play. rename updates the peer display name. avatar carries a data url under 40k chars, empty string clears it.
- mesh files: 'file-' datachannel, json header {id,msgId,name,size, mime} then binary chunks, sender closes for eof
- livekit files: {t:fileh,id,msgId,name,size,mime} json frame then binary chunk frames [0x46, u16 idLen, id, u32 seq, payload] under 12k
- Glare is resolved by perfect negotiation: the peer with the lexicographically larger id is polite
- Ephemeral by default. Chat and files are peer to peer and in-memory only, never persisted or relayed by the server. Room rows expire with QUICKCHAT_ROOM_TTL (default 7d). The websocket carries presence and signaling only, never message content.
- Style with the void theme tokens (bg-background, text-foreground, bg-card, border-border, text-muted-foreground). Never raw colors in components. Inter for UI, JetBrains Mono for ids and timestamps.
- The Quad4 mark ships as web/public/quad4-mark.svg and renders through a css mask in web/src/components/Mark.tsx. Do not inline svg markup. UI icons come from lucide-react.
- New dependencies must be at least 7 days old (enforced by minimumReleaseAge). Pin exact versions.
- Plain ASCII prose: no em dashes, no emojis, no semicolons in prose, no curly quotes, no unicode arrows.
- Code comments are plain text, no backticks around identifiers.
- All GitHub Actions pinned to full SHAs with version comments. Every job starts with step-security/harden-runner, top-level permissions {}.
- index.html carries a static landing skeleton for instant FCP. Keep it in sync with web/src/pages/HomePage.tsx hero markup.
- pnpm build runs scripts/clean-dist.mjs first (keeps dist/.gitkeep for go:embed), then vite build, then scripts/inline-css.mjs which inlines the css bundle into index.html to kill the render-blocking request.
- The vite test config excludes web/e2e so vitest never picks up playwright specs. Vitest runs in node, component tests opt into happy-dom per file with the @vitest-environment pragma (see MessageRow.test.tsx). e2e raises QUICKCHAT_RATE_* limits and lowers QUICKCHAT_MAX_FILE via env so the suite exercises limits without throttling.
- ML noise suppression (@workadventure/noise-suppression deepfilternet entry) is opt-in via the qc-nsup pref. The CSP carries 'wasm-unsafe-eval' in script-src for the model wasm, and while the toggle is on the mic must capture with noiseSuppression: false so the browser denoiser never stacks with the model. Any load failure or overload reverts to browser noise suppression.
- The scalar docs chunk is split out as docs-*.js and excluded from the service worker precache since it needs the live spec anyway.
- The demo build (VITE_DEMO=1) is the gh pages bundle for quickchat.quad4.io, served at the domain root so base stays /: hash routing, no service worker, a copy of openapi.yaml written into dist for /docs, and DemoMesh + DemoStage scripted peers instead of a server.
- Asset urls that ship in runtime js (css masks, the docs spec url) must use import.meta.env.BASE_URL so they resolve under a pages subpath.
- #root is locked to 100dvh with document overflow hidden, every region scrolls internally, never the page. Keep room panels min-h-0.
- Mobile room layout (<md): the stage collapses to a compact tile strip above the chat (Stage compact prop). A header toggle flips focus between chat and a full stage, badging unread chat while hidden. The composer autofocus is disabled on coarse pointers, enterKeyHint=send gives the mobile keyboard a send key. Safe area insets pad the header, controls bar, and composer.
- Signaling reports a state machine (connecting/online/reconnecting/ offline with attempt count) instead of a boolean. Chat surfaces it as a header pill plus a banner with a retry button. Peer join/leave lands in the log as system lines. Media connection state and per-participant connection quality surface on the stage and tiles.
- Playwright runs four projects: chromium desktop+mobile, firefox, webkit. live.test.ts activates only when LIVEKIT_URL is set. WebKit headless cannot resolve its own mdns ice candidates, so p2p mesh specs skip there, media e2ee is unsupported in webkit and must degrade to dtls-srtp with a visible warning.
- Remote participant volume lives in Stage via webAudioMix gain nodes (boost above 100 percent) and persists in localStorage qc-volumes.
- DebugPanel polls registered stats collectors every 2s while open, do not run getStats or other polling when the panel is closed.
- Error surfaces render through ErrorView (404, dead room, crash page). ErrorBoundary wraps routes (resets on navigation) and the Stage and ChatPane panels so one region crashing never takes down the room. /crash is an unlinked probe route for testing the boundary. Stale chunk errors trigger a guarded one-shot reload via lib/recovery.ts.