Rooms for a few friends: text, voice, and screen share over a peer-to-peer WebRTC mesh, brokered by a small Cloudflare Worker that never touches the media and cannot insert itself into a call: each WebRTC handshake is signed by the sender's identity key and checked by the receiver. Spec: .scratch/p2p-friends-chat/spec.md. Glossary: CONTEXT.md. Decisions: docs/adr/.
Without an invite link the site shows nothing but a notice: only friends get in, and only friends can invite. Anyone already in a room can start another: New room, behind ⚙ next to your name at the bottom of the sidebar, names it and shows its invite link, the app URL with the room's secret and name after #, for example https://dave.example.workers.dev/#Qm3v…/Game%20night. A friend who opens the link lands in that room; it is added under their other rooms and the secret is stripped from the address bar. A link can also be pasted in, on the front page or under Join by link behind the same ⚙: an app added to an iPhone or iPad home screen never receives a tapped link (Safari opens it, with storage of its own), so pasting is the only way a room reaches it. The server stores nothing but a hash of the secret, so a link is all a room is (docs/adr/0004). Old links without a name still work.
Each browser gets its own identity key. A friend you have not seen before wears a "new" badge until you click them. Names stand alone, even when two friends (or your phone and your PC) show the same one; click an avatar for the profile card: the six-character fingerprint, since when you know them, and a nickname only your browser shows. Your own card changes the name everyone sees, and clicking your own big avatar picks an emoji as your profile picture. The emoji is also your sound: you and your friends hear its little tune when you join a call and the same tune backwards and lower when you leave.
The sidebar shows each room by name: the one whose call you join goes to the top, the others sit at the bottom. A room's name row opens it, and folds it again. The room on screen stands out: first who is in it and not in the call, ready to join (you too, unless you are in a call elsewhere), then its call in a box; another room shows the faces of who is in it beside its name, a green ring for those in its call, who come first. Direct messages, folded at the bottom, lists everyone you have a private conversation with, offline friends too. The ⋯ next to the name of the room on screen holds Put on top (in every room but the top one, whose look the page wears), Invite, Themes, Clear chat history and Leave. The smiley next to the message box opens an emoji picker (search by name, browse by category, or paste one; Enter takes the first match); a count beside the name is messages that arrived while you were elsewhere. Join in a room enters its call (leaving any other call first); the call stays up while you read another room, and its shares stay on screen. Without a microphone, or with access to it denied, you still join: you listen, everyone sees you muted, and Unmute asks for the microphone again. Share screen publishes one share; nobody receives it until they click its tile. The gear next to Share screen tunes frame rate, resolution, and bandwidth live; the gear next to Mute picks devices and toggles audio processing. Leave under ⋯ forgets a room in this browser; the link gets you back in.
To rotate a room's secret: start a new room and send everyone its link.
About, among the links at the bottom of the sidebar (and on the front page), says which version this is: the commit message, when it was committed and deployed, and links to the commit and the GitHub project.
pnpm install
cp .dev.vars.example .dev.vars # local TURN placeholder
pnpm dev # SPA with HMR plus the Worker in workerd, one server at http://localhost:5173
pnpm test # core isolation check, then vitest inside workerd
pnpm typecheck
pnpm build # dist/client and dist/dave
pnpm e2e # Playwright: real browsers against a local e2e build (see below)
One suite, one switch:
pnpm e2e # against a local build, served by vite preview in workerd
pnpm e2e:nightly # the same suite against the deployed nightly Worker
The suite (e2e/, Playwright) drives several browser contexts, one identity each, through text, presence, calls, shares, settings, the phone layout, server outages and a hostile server. Every friend's socket runs through a proxy the test controls (e2e/fixtures.ts), which can cut it or rewrite frames; any unexpected console warning fails the test. The local build turns on the inspection hooks (VITE_E2E=1, src/client/hooks.ts) and uses the e2e Wrangler environment (no upgrade limit); master builds nightly with the same hooks, so both targets run everything. E2E_URL=<url> drives any other deployed copy, where tests that need the hooks skip themselves. E2E_FIREFOX=1 adds Firefox locally (pnpm exec playwright install firefox first), E2E_SLOW=4 stretches timeouts for a slow machine, -g <title> runs one test.
Nightly soak. .github/workflows/soak.yml runs e2e/soak/ against nightly every night at 01:00 UTC: four friends, Chromium and Firefox mixed, take seeded random actions in one room for 15 minutes (join, leave, mute, share, watch, settings, volumes, texts, reloads, a hidden tab, a cut socket, offline), and after every step everything they see is checked against a model (e2e/soak/model.ts). A failure is shrunk to a short action list that fails the same way, traced, and filed as an issue labelled soak with the commands that replay it (from the Actions tab: a seed and a step count, or the list itself; target local soaks a branch's build). It is not part of the release gate. E2E_URL=nightly SOAK_MINUTES=5 pnpm exec playwright test -c playwright.soak.config.ts runs it by hand.
Every other branch and pull request (except a draft's: its pushes skip these three until it is marked ready, draft-gate.yml) gets pnpm typecheck, pnpm test and a build from .github/workflows/ci.yml; .github/workflows/e2e.yml runs the browser suite in Chromium and Firefox against a local build on those branches and against nightly after each master deploy (also by hand from the Actions tab, against local, nightly or any URL). .github/workflows/red-green.yml runs the tests a branch adds or changes against the app from before the branch (before its first fix, if it has one) and says per test whether it fails there, the red-then-green receipt of a bug fix (scripts/red-green.mjs). Deploys run from GitHub Actions. A push to master deploys the nightly Worker, dave-nightly, a second copy of the app with its own rooms for trying things out; a push to prod deploys the live site, dave. Every open pull request from this repository, drafts included (the night shift's excepted), gets a preview too, .github/workflows/preview.yml: a Worker of its own, dave-pr-<number>, built like nightly and redeployed on every push, linked from one comment on the PR together with an invite link to a room of the PR's own, tested by the browser suite once deployed unless it is a draft (e2e.yml, called) and deleted when the PR closes; a weekly sweep (preview-sweep.yml) deletes any left behind. Releasing is a fast-forward of prod to master: git push origin master:prod. GitHub accepts it only for a commit that passed the whole browser suite against nightly: the e2e run after each master deploy ends by writing a nightly e2e passed commit status, which a ruleset on prod requires (and that run fails on flaky tests too). Agents working here follow CLAUDE.md, which also describes the /ship and /release routines. The nightly Worker is a Wrangler environment in wrangler.jsonc, chosen at build time through CLOUDFLARE_ENV; CLOUDFLARE_ENV=nightly pnpm build && pnpm exec wrangler deploy does the same by hand. Secrets live in GitHub repository secrets (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, TURN_KEY_API_TOKEN) and are pushed to whichever Worker is deployed; nothing secret is in this repo. The ROOM_SECRET secret from before rooms is unused and can be deleted.