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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user