π§± Meshcore β Architecture
Layersβ
| Layer | Technology | Role |
|---|---|---|
| P2P transport | WebRTC data channels (native) | Direct peer-to-peer message delivery |
| Signaling | Public MQTT broker (per-room topic) | Presence + SDP offer/answer/ICE exchange |
| Message channel | WebRTC data channel (n2) | Sending messages directly between browsers |
| Relay fallback | Own MQTT 3.1.1 client (WSS) | Delivering messages when WebRTC cannot connect (CGNAT / mobile networks) |
| Local bridge | BroadcastChannel | Tabs of the same browser (WebRTC loopback blocked) |
Key technical decisionsβ
1. Room = signaling topicβ
Every peer subscribes to n2mesh/{room} on a public MQTT broker and
announces its presence there. Peers that see each other's presence establish
a WebRTC connection. Changing the room = new topic = new peer group.
2. Glare-free offer selectionβ
Two peers may see each other's presence at the same instant, and both must not send offers (the WebRTC "glare" race). The peer with the lexicographically smaller id sends the offer; the other side waits for it:
if (state.pid < p.pid) {
dial(p.pid); // we are the offerer
}
Each peer id is random per session, so this always picks exactly one offerer.
3. Signaling messages over MQTTβ
Signaling rides the same per-room topic as everything else. Message kinds:
{ type: 'presence', pid, nick } // peer discovery
{ type: 'signal', from, to, data: { sdp } } // offer / answer
{ type: 'signal', from, to, data: { candidate } } // ICE candidate
{ u, t, ts, mid } // chat message
The to field addresses a specific peer; others ignore it. ICE candidates
arriving before the remote description are buffered per connection.
4. Relay fallback (MQTT) β why it existsβ
Pure P2P in the browser does not connect on mobile networks: carriers use CGNAT and reject hole-punching, and the free public TURN servers that used to bridge it are dead in 2026 or require an account (Cloudflare TURN needs API credentials).
The solution: every message is also published in parallel to the topic
n2mesh/{room} on a public MQTT broker over WSS. The MQTT client is an
own, dependency-free MQTT 3.1.1 implementation (~100 lines):
function mqttConnectPkt(clientId) { /* CONNECT: MQTT, v4, clean, keepalive 60 */ }
function mqttSubPkt(topic) { /* SUBSCRIBE qos 0 */ }
function mqttPubPkt(topic, p) { /* PUBLISH qos 0 */ }
function mqttUnsubPkt(topic) { /* UNSUBSCRIBE on room change */ }
- Room isolation: every tab subscribes to the exact topic of its room (no wildcards) β traffic from other rooms never reaches it.
- Deduplication by ID: every message has a unique
mid; aMapremembers recent IDs for 30 s, so a message received over P2P and relay and the local bridge is displayed once. - Offline queueing: messages sent before the broker connection lands in
relay.queue(with the room remembered) and are flushed after CONNACKβSUBACK. - Brokers:
wss://broker.hivemq.com:8884(verified end-to-end), backupwss://broker.emqx.io:8084, failover every 3 attempts. - Keepalive: PINGREQ every 30 s, reconnect with backoff (1 s β 15 s).
The status shows which path the connection uses: P2P, P2P + relay,
relay mode (phone on CGNAT), connectingβ¦.
5. Local bridge (BroadcastChannel)β
Two tabs of the same browser cannot connect via WebRTC (browsers block
WebRTC loopback). The BroadcastChannel bridge connects them locally: tabs
of the same browser exchange hello/msg directly, while different
browsers/devices go through WebRTC data channels and/or the relay.
6. Why WebTorrent was removedβ
The original version found peers through public WebTorrent WebSocket trackers
(tracker.webtorrent.dev, tracker.openwebtorrent.com) and carried messages
over the BitTorrent extended protocol. Live testing (August 2026) showed the
trackers accept announces and register peers but never relay WebRTC offers
between them β two clients in the same swarm (complete=2) received zero
offers. The browser build of WebTorrent can only use WebSocket trackers (no
UDP/DHT in the browser), so peers could never discover each other and P2P was
dead. Replacing it with native WebRTC + MQTT signaling keeps the app fully
serverless, zero-dependency, and working today.
Filesβ
| File | Contents |
|---|---|
index.html | Single-page shell |
app.js | Networking: WebRTC P2P + MQTT signaling/relay + local bridge |
style.css | Dark aurora theme |
.github/workflows/deploy.yml | Deploy to GitHub Pages |
Data flowβ
sender: JSON({u, t, ts, mid}) βββΊ dc.send(payload) (P2P data channel)
βββΊ mqttPubPkt('n2mesh/room', p) (relay)
β
peer receives via the data channel OR via the MQTT broker
βΌ
receiver: handlePayload(bytes) ββ dedup by mid βββΊ addMessage(nick, text)
discovery: presence on 'n2mesh/room' βββΊ dial() βββΊ SDP/ICE via relay βββΊ data channel open
Limitationsβ
- No history β when you leave the room, the peer group is gone (the price of having no server).
- Both peers must be online simultaneously β the relay does not store messages (QoS 0, no retention).
- Relay mode = messages pass through a public broker β a deliberate trade-off so the chat works on networks where P2P is impossible (CGNAT). P2P mode stays fully peer-to-peer.