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 postSignal(toID, payload) { await postMessage({ type: "signal", to: toID, signal: payload }); }
|
||||||
|
|
||||||
async function handleOffer(fromID, sdp) {
|
function newPeer(remoteID, asOfferer) {
|
||||||
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" }] });
|
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
|
||||||
r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
|
const r = { pc, dc: null, lastSeen: Date.now(), status: "new", role: asOfferer ? "offerer" : "answerer" };
|
||||||
remotePeers.set(fromID, r);
|
remotePeers.set(remoteID, r);
|
||||||
refreshPill(); renderPeers();
|
|
||||||
|
|
||||||
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 = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
r.status = pc.connectionState; refreshPill(); renderPeers();
|
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 wireChannel = (ch) => {
|
||||||
const ch = ev.channel; r.dc = ch;
|
r.dc = ch;
|
||||||
ch.onopen = () => { refreshPill(); renderPeers(); };
|
ch.onopen = () => { refreshPill(); renderPeers(); };
|
||||||
ch.onclose = () => { 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 });
|
async function initiateOffer(toID) {
|
||||||
const answer = await pc.createAnswer();
|
if (remotePeers.has(toID)) return;
|
||||||
await pc.setLocalDescription(answer);
|
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 });
|
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) {
|
async function handleIce(fromID, candidate) {
|
||||||
const r = remotePeers.get(fromID);
|
const r = remotePeers.get(fromID);
|
||||||
if (r && r.pc) { try { await r.pc.addIceCandidate(candidate); } catch (e) {} }
|
if (r && r.pc) { try { await r.pc.addIceCandidate(candidate); } catch (e) {} }
|
||||||
@@ -312,9 +339,21 @@
|
|||||||
const p = m.signal;
|
const p = m.signal;
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
if (p.kind === "offer" && p.sdp) handleOffer(m.from, p.sdp.sdp);
|
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);
|
else if (p.kind === "ice" && p.candidate) handleIce(m.from, p.candidate);
|
||||||
} catch (e) {}
|
} 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);
|
es.onerror = () => setTimeout(connectFeed, 2000);
|
||||||
}
|
}
|
||||||
connectFeed();
|
connectFeed();
|
||||||
|
|||||||
Reference in New Issue
Block a user