web: deterministic browser-to-browser pairing via presence

Browsers were always answerers — meant two browsers in the same room
(no Go peer present) would both wait forever for an offer that no one
would send. Now: on presence from another peer, the browser with the
smaller peerID (lexicographic) initiates the offer; the other side
answers. Tie-break in handleOffer handles the race where both posted
presence simultaneously and could double-offer.

This makes pure-browser meshes work — phone + laptop in the same room
auto-pair without needing a Go peer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.7
2026-05-21 01:24:23 -05:00
parent 099c66ab5a
commit a0f7498663

View File

@@ -262,32 +262,59 @@
}
async function postSignal(toID, payload) { await postMessage({ type: "signal", to: toID, signal: payload }); }
async function handleOffer(fromID, sdp) {
let r = remotePeers.get(fromID);
if (r && r.pc) { try { r.pc.close(); } catch (_) {} }
function newPeer(remoteID, asOfferer) {
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
remotePeers.set(fromID, r);
refreshPill(); renderPeers();
const r = { pc, dc: null, lastSeen: Date.now(), status: "new", role: asOfferer ? "offerer" : "answerer" };
remotePeers.set(remoteID, r);
pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() }); };
pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(remoteID, { kind: "ice", candidate: ev.candidate.toJSON() }); };
pc.onconnectionstatechange = () => {
r.status = pc.connectionState; refreshPill(); renderPeers();
if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(fromID);
if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(remoteID);
};
pc.ondatachannel = (ev) => {
const ch = ev.channel; r.dc = ch;
const wireChannel = (ch) => {
r.dc = ch;
ch.onopen = () => { refreshPill(); renderPeers(); };
ch.onclose = () => { refreshPill(); renderPeers(); };
ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true);
ch.onmessage = (m) => addIncoming(m.data, remoteID.slice(0, 12), Date.now(), true);
};
if (asOfferer) {
// We create the channel; peer will receive via ondatachannel
wireChannel(pc.createDataChannel("tether"));
} else {
pc.ondatachannel = (ev) => wireChannel(ev.channel);
}
refreshPill(); renderPeers();
return r;
}
await pc.setRemoteDescription({ type: "offer", sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
async function initiateOffer(toID) {
if (remotePeers.has(toID)) return;
const r = newPeer(toID, true);
const offer = await r.pc.createOffer();
await r.pc.setLocalDescription(offer);
await postSignal(toID, { kind: "offer", sdp: offer });
}
async function handleOffer(fromID, sdp) {
let r = remotePeers.get(fromID);
// Tie-break: if we're already the offerer to this peer and our id sorts
// lower than theirs, ignore their offer — they should be answering ours.
if (r && r.role === "offerer" && peerID < fromID) return;
if (r && r.pc) { try { r.pc.close(); } catch (_) {} remotePeers.delete(fromID); }
r = newPeer(fromID, false);
await r.pc.setRemoteDescription({ type: "offer", sdp });
const answer = await r.pc.createAnswer();
await r.pc.setLocalDescription(answer);
await postSignal(fromID, { kind: "answer", sdp: answer });
}
async function handleAnswer(fromID, sdp) {
const r = remotePeers.get(fromID);
if (!r || !r.pc || r.role !== "offerer") return;
try { await r.pc.setRemoteDescription({ type: "answer", sdp }); } catch (e) {}
}
async function handleIce(fromID, candidate) {
const r = remotePeers.get(fromID);
if (r && r.pc) { try { await r.pc.addIceCandidate(candidate); } catch (e) {} }
@@ -312,9 +339,21 @@
const p = m.signal;
if (!p) return;
if (p.kind === "offer" && p.sdp) handleOffer(m.from, p.sdp.sdp);
else if (p.kind === "answer" && p.sdp) handleAnswer(m.from, p.sdp.sdp);
else if (p.kind === "ice" && p.candidate) handleIce(m.from, p.candidate);
} catch (e) {}
});
es.addEventListener("presence", (ev) => {
try {
const m = JSON.parse(ev.data);
if (!m.from || m.from === peerID) return;
// Deterministic initiator: the smaller-id peer creates the offer.
// If we already have a PC for them, do nothing.
if (peerID < m.from && !remotePeers.has(m.from)) {
initiateOffer(m.from);
}
} catch (e) {}
});
es.onerror = () => setTimeout(connectFeed, 2000);
}
connectFeed();