v0.4: symmetric presence chirps + per-peer mesh

Both Go peer and browser now broadcast {type:"presence", from, role}
every 10s on the bus. When either side sees a presence from someone
they don't yet have a RTCPeerConnection to, they initiate a new one
targeted at that specific peerID via the new "to" field on signal
messages. Each side keeps a map<peerID, RTCPeerConnection> instead of
the v0.3 single-connection model.

This means:
- N browsers can pair with M peers (true mesh)
- New tabs auto-discover existing peers via their next 10s chirp
- Restarts and network blips recover within 10s instead of needing
  a manual browser refresh
- 45s lastSeen timeout sweeps disconnected peers and tears down their
  PeerConnection

The browser UI now shows a row of peer chips that flip green when their
DataChannel opens. The pill shows "rtc" if *any* peer is open, else
"negotiating" if any are in progress, else "sse".

Go side regenerates a random peerID per process start (was static).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.7
2026-05-21 01:09:24 -05:00
parent 6ea7ed579e
commit 7995908c87
2 changed files with 319 additions and 132 deletions

View File

@@ -63,6 +63,9 @@
}
.meta { font-size: 11px; color: #666; margin-top: 4px; }
.meta .rtc-badge { color: #4ade80; }
.peers { font-size: 11px; color: #666; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 6px; }
.peers .peer { background: #131313; padding: 2px 8px; border-radius: 999px; border: 1px solid #1f1f1f; }
.peers .peer.rtc { color: #4ade80; border-color: #052e16; }
footer {
margin-top: auto; padding-top: 8px;
font-size: 11px; color: #555; text-align: center;
@@ -73,12 +76,14 @@
<body>
<header>
<h1>tether</h1>
<span class="tag">phone ↔ laptop</span>
<span class="tag">mesh clipboard</span>
<span class="pill" id="rtcPill">sse</span>
</header>
<div class="peers" id="peerList"></div>
<div class="row">
<label for="out">send to laptop</label>
<label for="out">send</label>
<textarea id="out" placeholder="paste or type something…"></textarea>
<div class="actions">
<button id="pasteBtn">paste clipboard</button>
@@ -89,18 +94,18 @@
<div class="status" id="status">idle</div>
<div class="feed">
<h2>received from laptop</h2>
<h2>received</h2>
<div id="incoming"></div>
</div>
<footer>
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.3
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.4
</footer>
<script>
const $ = (id) => document.getElementById(id);
const peerID = "tether-browser-" + Math.random().toString(36).slice(2, 8);
let rtcChannel = null; // open RTCDataChannel, when we have one
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
const remotePeers = new Map(); // remoteID -> {pc, dc, lastSeen, status}
const status = (msg, cls) => {
const s = $("status");
@@ -112,6 +117,25 @@
p.textContent = mode;
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : "");
};
function refreshPill() {
let anyRTC = false;
let anyConnecting = false;
for (const r of remotePeers.values()) {
if (r.dc && r.dc.readyState === "open") anyRTC = true;
else if (r.status === "connecting" || r.status === "new") anyConnecting = true;
}
setPill(anyRTC ? "rtc" : (anyConnecting ? "negotiating" : "sse"));
}
function renderPeers() {
const el = $("peerList");
el.innerHTML = "";
for (const [id, r] of remotePeers.entries()) {
const span = document.createElement("span");
span.className = "peer" + ((r.dc && r.dc.readyState === "open") ? " rtc" : "");
span.textContent = id.slice(0, 12) + (r.dc?.readyState === "open" ? " 🟢" : " ⋯");
el.appendChild(span);
}
}
function addIncoming(text, source, ts, viaRTC) {
const el = document.createElement("div");
@@ -138,93 +162,122 @@
$("sendBtn").addEventListener("click", async () => {
const text = $("out").value;
if (!text) { status("empty", "err"); return; }
if (rtcChannel && rtcChannel.readyState === "open") {
rtcChannel.send(text);
status("sent via rtc ✓", "ok");
} else {
status("sending via http…");
try {
const r = await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID }),
});
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
} catch (e) { status("network error", "err"); }
// Fan out to all open DataChannels first
let rtcSent = 0;
for (const r of remotePeers.values()) {
if (r.dc && r.dc.readyState === "open") {
try { r.dc.send(text); rtcSent++; } catch (e) {}
}
}
if (rtcSent > 0) {
status(`sent via rtc to ${rtcSent} peer(s) ✓`, "ok");
return;
}
// Fallback to HTTP for anyone not RTC-paired
status("sending via http…");
try {
const r = await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID }),
});
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
} catch (e) { status("network error", "err"); }
});
// ── WebRTC peer ────────────────────────────────────────────────────────
// Browser acts as ANSWERER. Listens for offers via SSE, replies via /api/send.
async function postSignal(payload) {
// ── Signaling ─────────────────────────────────────────────────────────
async function postMessage(m) {
m.from = m.from || peerID;
m.source = m.source || "web";
await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "signal",
from: peerID,
source: "web",
signal: payload,
}),
body: JSON.stringify(m),
});
}
async function postSignal(toID, payload) {
await postMessage({ type: "signal", to: toID, signal: payload });
}
let pc = null;
async function handleOffer(sdp) {
if (pc) try { pc.close(); } catch (_) {}
pc = new RTCPeerConnection({
// We accept incoming offers (we're the answerer). Create a fresh PC per
// remote peer.
async function handleOffer(fromID, sdp) {
// If we already have a pc for this peer, close it (re-negotiate).
let r = remotePeers.get(fromID);
if (r && r.pc) { try { r.pc.close(); } catch (_) {} }
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
setPill("negotiating");
r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
remotePeers.set(fromID, r);
refreshPill();
renderPeers();
pc.onicecandidate = (ev) => {
if (ev.candidate) postSignal({ kind: "ice", candidate: ev.candidate.toJSON() });
if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() });
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "connected") setPill("rtc");
else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setPill("sse");
r.status = pc.connectionState;
refreshPill();
renderPeers();
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
remotePeers.delete(fromID);
}
};
pc.ondatachannel = (ev) => {
const ch = ev.channel;
ch.onopen = () => { rtcChannel = ch; setPill("rtc"); };
ch.onclose = () => { rtcChannel = null; setPill("sse"); };
ch.onmessage = (m) => addIncoming(m.data, "laptop", Date.now(), true);
r.dc = ch;
ch.onopen = () => { refreshPill(); renderPeers(); };
ch.onclose = () => { refreshPill(); renderPeers(); };
ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true);
};
await pc.setRemoteDescription({ type: "offer", sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await postSignal({ kind: "answer", sdp: answer });
await postSignal(fromID, { kind: "answer", sdp: answer });
}
async function handleIceCandidate(candidate) {
if (pc) {
try { await pc.addIceCandidate(candidate); } catch (e) {}
async function handleIce(fromID, candidate) {
const r = remotePeers.get(fromID);
if (r && r.pc) {
try { await r.pc.addIceCandidate(candidate); } catch (e) {}
}
}
// ── Presence chirp ────────────────────────────────────────────────────
setInterval(() => {
postMessage({ type: "presence", role: "browser" }).catch(() => {});
}, 10000);
// also do one immediately on load
postMessage({ type: "presence", role: "browser" }).catch(() => {});
// ── SSE main feed ──────────────────────────────────────────────────────
function connectFeed() {
const es = new EventSource("/api/stream");
es.addEventListener("clipboard", (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.from !== peerID) { // hide our own sends, show everyone else
addIncoming(m.text, m.source, m.ts, false);
}
if (m.from !== peerID) addIncoming(m.text, m.source, m.ts, false);
} catch (e) {}
});
es.addEventListener("signal", (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.from === peerID) return; // ignore our own
if (m.from === peerID) return;
if (m.to && m.to !== peerID) return; // not for us
const payload = m.signal;
if (!payload) return;
if (payload.kind === "offer" && payload.sdp) {
handleOffer(payload.sdp.sdp);
} else if (payload.kind === "ice" && payload.candidate) {
handleIceCandidate(payload.candidate);
}
if (payload.kind === "offer" && payload.sdp) handleOffer(m.from, payload.sdp.sdp);
else if (payload.kind === "ice" && payload.candidate) handleIce(m.from, payload.candidate);
} catch (e) {}
});
es.addEventListener("presence", (ev) => {
// Browsers just note presence; peers (Go side) act on it.
// We could render a "peer online" indicator here later.
});
es.onerror = () => setTimeout(connectFeed, 2000);
}
connectFeed();