https://chat-to-chat-publicvm.com
A live, ephemeral, peer-to-peer chat that runs as a plain static website.
- No backend. No database, no server, no API of yours, no WebSocket server you run.
- Session-only history. Nothing is ever written to disk. New joiners are handed the recent conversation by the people currently in the room (peer-to-peer); when the last person leaves, the history is gone for good.
- One room per site. Everyone who opens the same site (same host and path) lands in the same global chat. A different site — a different domain, or even a different
/repo/on the same*.github.iohost — is automatically its own separate room. - Direct & private. Messages travel browser-to-browser over end-to-end-encrypted WebRTC. They never pass through any relay or server.
Just static files → drop them on GitHub Pages (or any static host) and it works.
Two browsers on different devices can't find each other on the internet by themselves — something has to introduce them once (this is called signaling). GitHub Pages can't do that (it only serves files).
This app uses Trystero over the public Nostr network to do that one introduction. You run and pay for nothing — the handshake rides on free public Nostr relays that other people operate.
After the introduction, every message goes directly peer-to-peer over an encrypted WebRTC data channel. The relays only ever see the encrypted connection setup, never your messages.
Browser A ──(encrypted handshake via free public Nostr relays)── Browser B
│ │
└──────────── direct, end-to-end-encrypted P2P messages ───────────┘
(no server in the middle, nothing stored)
The Trystero library is vendored into src/vendor/trystero-nostr.bundle.js (one self-contained file, no runtime CDN dependency).
Editable sources live in src/ (never published). A tiny build inlines them into a
single self-contained page in docs/, which is what GitHub Pages serves — so the
only thing reachable on the live site is the page itself (the app). No app.js,
style.css, or vendor/ exist as separate URLs to open.
src/ ← editable sources (repo-only, NOT served)
index.template.html page markup (gate, 3-column layout, composer)
style.css dark, responsive UI
app.js all logic (Trystero wiring + history hand-off + UI)
vendor/trystero-nostr.bundle.js bundled Trystero (Nostr strategy)
build.mjs inlines src/* → docs/index.html (+ docs/404.html)
docs/ ← the published site (GitHub Pages source = /docs)
index.html single self-contained page (CSS + JS all inlined)
404.html same app + a 404 flag; any unknown URL serves the app
CNAME .nojekyll GitHub Pages config (the only other served files)
README.md .gitignore repo-only files (not served)
After editing anything in src/, regenerate the published page:
node build.mjsThis inlines the CSS + JS + vendor bundle into docs/index.html and writes an
identical docs/404.html.
WebRTC needs http(s):// — file:// won't work. Build, then serve docs/:
node build.mjs && cd docs && python3 -m http.server 8080
# then open http://localhost:8080Open it in two browser windows/tabs (or two browsers). They share the localhost
room and connect to each other.
-
Build and push:
node build.mjs git add . && git commit -m "deploy" git push origin main
-
On GitHub: Settings → Pages → Build and deployment → Source: "Deploy from a branch", pick
mainand the/docsfolder, save. -
After a minute your site is live at
https://<you>.github.io/<repo>/.
Publishing from
/docs(which contains only the inlined page) is what keeps the sources andREADME.mdoff the live site;docs/404.htmlmakes any unknown URL — including/README.md,/app.js, etc. — silently land on the app, which cleans the address bar back to/with no reload and no error.
- In Settings → Pages → Custom domain, enter your domain and save (GitHub keeps
the
docs/CNAMEfile in sync). - At your DNS provider, point the domain at GitHub Pages:
- apex domain
chat-to-chat.com→ fourArecords:185.199.108.153,185.199.109.153,185.199.110.153,185.199.111.153 - and/or
www→CNAMEto<you>.github.io.
- apex domain
- Enable Enforce HTTPS once the certificate is issued.
Because the room is keyed to host + path, your custom domain (served at the root) gets its own dedicated room — separate from the
https://<you>.github.io/<repo>/URL it was reachable at before.
Built-in STUN handles most networks, but users behind strict/symmetric NATs (most
mobile carriers, some corporate networks) can't form a direct P2P link with STUN
alone — they discover each other and then the connection fails. To cover them,
CONFIG.turnConfig ships with the Open Relay Project's free public TURN
(static shared credentials, made for serverless sites). ICE only routes through
TURN when a direct path is impossible, and the traffic stays end-to-end encrypted
either way.
For heavier traffic or more reliability, swap in your own TURN relay in
src/app.js → CONFIG.turnConfig (then rebuild):
turnConfig: [
{urls: 'turn:your-turn-host:3478', username: 'user', credential: 'pass'},
],Options: Open Relay / Metered (bigger free tier with an account) or any self-hosted coturn. Note: Cloudflare TURN issues short-lived credentials via API, which needs a server — not usable from a purely static site.
Everything tweakable lives in CONFIG at the top of src/app.js (run node build.mjs after):
| Field | What it does |
|---|---|
appId |
Your app's namespace on the network. Change it if you fork this. |
roomId |
Default = host (+ /repo/ subpath on project sites); a #r=<name> (name + required password) makes a private room. |
relayUrls / relayRedundancy |
Curated public Nostr relays + how many to use (pinned for reliable discovery). |
historyShare |
How many recent messages a newcomer is handed (default 30). |
meshCap |
Soft cap before suggesting an overflow room (default 12). |
flood |
Per-channel inbound rate limits (msg/name/typing/react/presence). |
turnConfig |
Optional TURN servers (see above). |
maxNameLen / maxMsgLen |
Input length caps. |
minPassLen |
Minimum private-room password length (default 8 — it's key material). |
maxFileBytes |
Largest file/image a peer may send (default 10 MB). |
Rooms & sharing: the default room is the whole domain (public). Click the Rooms
button (top-right) to create or join a private room by name — pick any name/number
plus a required password (min 8 characters). Only people who enter the same name
and password connect: the password is first stretched with PBKDF2 (SHA-256,
310k iterations, salted with the room id) and the result becomes the channel's
encryption key, so a wrong password reaches no one and a captured handshake can't be
brute-forced offline at raw-hash speed (the password never goes in the URL, and is
wiped from sessionStorage right after it's read). Still: the room is only as strong
as the passphrase — prefer a few random words over password1. Invite by sharing the
link (#r=<name>) and giving people the password separately. Rooms are just
room-ids over the free relays — nothing is stored, and a room vanishes the moment its
last person leaves. No server, no database.
- Pick a nickname (no account, no password) — remembered locally; live avatar + a "🎲 surprise me" generator on the join screen.
- Live messages, end-to-end encrypted, never stored on disk, with safe linkify +
minimal markdown (
**bold**,_italic_,`code`) rendered as DOM nodes only. - Live history hand-off — a newcomer is handed the last ~30 messages (and their reactions) by the single elected peer already in the room.
- Verifiable hearsay — every message carries SHA-256 links to the messages before it, weaving a causal hash-DAG as the room talks. A newcomer recomputes the hashes of the handed-off history (the relayer can't silently alter or drop anything inside the chain) and then asks the other people present to vouch: if an independent peer's latest hashes chain down into the burst, the room shows "✓ History verified — independently confirmed by N other people here." Forging history now requires everyone present to collude, not just one elected peer.
- Private rooms by custom name + required password (
#r=<name>, cryptographically gated), and an overflow-room suggestion when a mesh gets large. - Peer-to-peer file & image sharing — attach (📎), drag-and-drop onto the chat, or
paste an image; it streams directly browser-to-browser over the encrypted WebRTC
channel (chunked, with a live progress bar), never through any server. Images render
inline and open in a full-screen viewer (with a download button); other files
arrive as a download card. Up to 10 MB; live-only (not stored, not part of the
history hand-off) and the in-memory blob is freed when it scrolls off, on
/clear, or when you leave. - Automatic image optimisation — big photos are downscaled and re-encoded to WebP in your browser before they hit the wire (a 1.1 MB shot becomes ~190 KB, and pictures that used to exceed the cap now send at all). Pure canvas work: the pixels never touch a server, and the original is used if compression wouldn't help.
- Multi-line messages — Enter sends, Shift+Enter starts a new line, and the composer grows with the text up to a cap.
- Delivery state on your own messages —
◌while sending,✓once it reached the people present, and an honest·("no one else is here yet") instead of a tick that pretends someone received it. A failed send shows⚠and is click-to-retry; peers dedupe on the message id, so a retry can never post a double. - Copy any message from the message toolbar (with a fallback for browsers that refuse the async clipboard).
- React and reply to shared files and images, not just text messages.
- A character counter that stays out of the way until you're near the 4,000-character cap, then counts down (and turns red for the last 120).
- Touch gestures — swipe a message right to reply, long-press for the react/reply/copy toolbar (a plain tap just dismisses the keyboard, as it should), each with a light haptic tap where the device supports it.
- Readable date separators —
Yesterday · 14:32,Tuesday · 09:05, and just the time for today. - Come-back-later cues — a "New messages" divider marks where you left off, and the jump button counts what you missed ("↓ 3 new messages"). Scroll back through the log and it offers a plain "↓ Latest" to get you home again.
- Optional chime & desktop notifications — both off by default, both opt-in from Settings. The chime is synthesised with WebAudio (no audio file, no request) and notifications only fire while the tab is in the background.
- Emoji reactions, reply / quote, slash commands (
/nick /me /who /clear /help /shrug), and a searchable emoji picker with recents. - Unique display names per room — case-insensitive; a taken name is blocked at the
join screen (and
/nickrefuses it). If a duplicate ever slips through (a rare race), the later peer gets a forced-rename popup and must pick a free name to keep chatting — no silent auto-numbering. Best-effort (no server; deliberate spoofing still shows the#idfingerprint badge). - Per-user mute (session-only), idle/away presence, typing indicators, timestamps + time-gap dividers, and a scroll-to-latest / unread tab badge.
- Light / dark / system theme, safe-view blur, who's-online list, community guidelines, connection + relay/latency panel, and an honest IP-exposure notice.
- "Hide my IP from peers" (Settings, off by default) — WebRTC has to tell the other
side where to send packets, so by default your address list is handed to every peer in
the room as ordinary text. This sets
iceTransportPolicy: 'relay', which makes the browser skip host and server-reflexive gathering entirely and only allocate on the TURN server, so peers receive the relay's address where yours used to be. The trade is stated plainly in the UI: everything routes through a free public relay, so it is slower, and there is no direct-path fallback — if that relay is unreachable the connection fails outright. It does not hide you from the Nostr relays or the TURN operator, because you connect straight to those; only Tor or a VPN covers that. - Built for keyboards & screen readers: focus rings, focus-trapped mobile drawers,
keyboard-navigable emoji grid, live regions, skip link, and forced-colors support.
Popovers keep
aria-expandedhonest and hand focus back to their button on Escape. - Motion with a purpose — rows drift in from the side their bubble sits on, panels
and menus scale up from the control that opened them, the theme swap cross-fades, and
every control has press feedback. All of it collapses to nothing under
prefers-reduced-motion. - 3-column layout on desktop; slide-in drawers on mobile.
- Security/abuse hardening: transport-namespaced message ids (no silent suppression), sanitization of bidi/zero-width/control chars, per-channel flood limits, and reply/reaction/history that never trust claimed identity.
- Built to stay fast in a busy room: message rows are built once and reused, so a
mute toggle or a history hand-off reorders existing nodes instead of rebuilding
thousands of elements; off-screen rows are skipped by the engine
(
content-visibility); duplicate-name detection is a set lookup rather than a scan per message; file-transfer progress writes to a retained node instead of querying the DOM hundreds of times; and sticking to the newest message is coalesced into one scroll write per frame, so a burst of ten messages costs one layout instead of ten. Scroll handling is rAF-coalesced too (a flicked list fires dozens of events per frame, each of which used to force a layout), join/leave lines append instead of rebuilding the list, and the mobile-vs-desktop breakpoint is read from a singleMediaQueryListrather than allocating one per touch. Measured on a 400-message room: a full re-render went from ~63 ms of blocked main thread to ~7 ms, and on a 6×-throttled CPU (mid-range phone proxy) incoming messages went from 4.8 ms to 2.6 ms each and first paint from 870 ms to 640 ms.
Three different things strand a peer-to-peer session, and only one of them announces itself:
- The device loses the network —
online/offlinefires, which is the easy case. - The device switches network (wi-fi ↔ cellular) — nothing fires at all.
navigator.onLinestaystruethe whole time, because there is still a network; it just has a different address, and every relay socket and peer connection is now dead. This is the case people actually hit — walking out of the house with the app open. Handled by listening tonavigator.connection'schangeevent, the one signal this transition does produce. - Sockets die quietly — sleep, NAT timeout, a relay restart, packets being blackholed by a captive portal. No event of any kind.
So the app doesn't trust events alone. A watchdog judges the connection by how long it has been since the last genuinely healthy signal (an open relay socket or a live peer) and rejoins with exponential backoff, capped, resetting the moment a peer connects. Sockets still handshaking earn a longer grace than a set that has gone fully closed, so a slow network isn't torn down mid-connect.
Two details that matter more than they look:
- The watchdog runs even when the tab is hidden. It used to return early on
document.hidden, which on a phone is most of the time — so a session that died in the background stayed dead. pagehidefires both for a real unload and when the page enters the back/forward cache, which on mobile is every time you switch apps. Leaving the room there meant backgrounding the browser quietly dropped you out of the chat. It now only leaves on a genuine unload, and rejoins onpageshow.
What none of this fixes is throughput. Speed over a direct peer connection is whatever the two networks give you; when NAT forces traffic through TURN it is whatever the free public relay gives you, which is not much. A dedicated TURN server is the only real answer there — see Connectivity above.
- History is session-only: a newcomer only sees history if someone is still in the room to hand it over. Once everyone leaves, the conversation is gone for good.
- WebRTC needs that one-time signaling via public relays; if every public relay were down at once, new connections couldn't form (existing P2P links keep working).
- Browsers cap simultaneous WebRTC connections, so this suits small/medium rooms, not thousands of concurrent peers in one mesh.
- No server means no referee. Two mechanisms trust the random per-session peer
id ordering: name-collision resolution (higher id yields) and history-sender
election (lowest id sends). A determined attacker could regenerate ids until
they hold a very low one, letting them win name clashes or get elected to hand
newcomers history. The hash-DAG cross-check (see Verifiable hearsay) means a
forged history is only believed if every other connection present colludes (or
nobody else is there to vouch — the room tells you which). Note the word
connection: peer ids are free, so one person running several tabs counts as
several "independent" witnesses — treat the confirmation count as connections,
not people. Self-impersonation is rejected, reply quotes and live messages never
trust claimed identity, and the
#idbadge always exposes same-named peers. - The 10 MB file cap is enforced on what gets kept/shown, not on the wire: a hostile peer could still stream you junk bytes before the cap rejects it. Mute (or leave) cuts them off.