466 lines
13 KiB
JavaScript
466 lines
13 KiB
JavaScript
class MeshNet {
|
||
constructor() {
|
||
this.myPeerId = generatePeerId();
|
||
this.connections = new Map();
|
||
this._pendingMeshPeers = new Map();
|
||
this._pendingPeer = null;
|
||
this._currentQRData = null;
|
||
this._fullSDPData = null;
|
||
this.isHost = false;
|
||
this.hostPeerId = null;
|
||
this.roster = [];
|
||
this.readyPeers = new Set();
|
||
this._meshReadySent = false;
|
||
this.roomCode = null;
|
||
this._processedAnswers = new Set();
|
||
this._answerPollInterval = null;
|
||
|
||
this.onpeerconnect = null;
|
||
this.onpeerdisconnect = null;
|
||
this.ondata = null;
|
||
this.onqrupdate = null;
|
||
this.onanswerready = null;
|
||
this.onreadyupdate = null;
|
||
}
|
||
|
||
_generateRoomCode() {
|
||
return Math.random().toString(36).substring(2, 6).toUpperCase();
|
||
}
|
||
|
||
_startAnswerPolling() {
|
||
this._stopAnswerPolling();
|
||
this._answerPollInterval = setInterval(() => {
|
||
if (!this.isHost || !this.roomCode || !this._pendingPeer || this._pendingPeer.signalReceived) return;
|
||
const prefix = `rp2p_answer_${this.roomCode}_`;
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i);
|
||
if (key && key.startsWith(prefix) && !this._processedAnswers.has(key)) {
|
||
this._processedAnswers.add(key);
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) continue;
|
||
const data = JSON.parse(raw);
|
||
this.feedAnswer(JSON.stringify({ v: 1, id: data.id, s: data.s }));
|
||
} catch (err) {
|
||
console.error('poll answer error:', err);
|
||
}
|
||
}
|
||
}
|
||
}, 400);
|
||
}
|
||
|
||
_stopAnswerPolling() {
|
||
if (this._answerPollInterval) {
|
||
clearInterval(this._answerPollInterval);
|
||
this._answerPollInterval = null;
|
||
}
|
||
}
|
||
|
||
/* ─── Host: room ─── */
|
||
|
||
createRoom() {
|
||
this.isHost = true;
|
||
this.roomCode = this._generateRoomCode();
|
||
this._processedAnswers = new Set();
|
||
this._cleanupStorage();
|
||
this._createPendingPeer();
|
||
this._startAnswerPolling();
|
||
}
|
||
|
||
_cleanupStorage() {
|
||
const toRemove = [];
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i);
|
||
if (key && (key.startsWith('rp2p_offer_') || key.startsWith('rp2p_answer_'))) {
|
||
toRemove.push(key);
|
||
}
|
||
}
|
||
toRemove.forEach(k => localStorage.removeItem(k));
|
||
}
|
||
|
||
_createPendingPeer() {
|
||
if (this._pendingPeer) {
|
||
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
||
}
|
||
|
||
const peer = new SimplePeer({ initiator: true, trickle: false, config: { iceServers: [] } });
|
||
let signalSent = false;
|
||
|
||
peer.on('signal', signal => {
|
||
if (signalSent) return;
|
||
signalSent = true;
|
||
|
||
const sdpJson = JSON.stringify({ v: 1, id: this.myPeerId, s: signal });
|
||
this._fullSDPData = sdpJson;
|
||
|
||
if (this.roomCode) {
|
||
localStorage.setItem(`rp2p_offer_${this.roomCode}`, sdpJson);
|
||
this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, room: this.roomCode });
|
||
} else {
|
||
this._currentQRData = sdpJson;
|
||
}
|
||
|
||
if (this.onqrupdate) this.onqrupdate(this._currentQRData);
|
||
});
|
||
|
||
peer.on('connect', () => {
|
||
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||
});
|
||
|
||
peer.on('data', data => {
|
||
try {
|
||
const msg = JSON.parse(data.toString());
|
||
this._handleMessage(msg, peer);
|
||
} catch (e) {
|
||
console.error('p2p data error:', e);
|
||
}
|
||
});
|
||
|
||
peer.on('close', () => this._cleanupPeer(peer));
|
||
peer.on('error', err => console.error('p2p error:', err));
|
||
|
||
this._pendingPeer = { peer, signalSent, connected: false, peerId: null };
|
||
}
|
||
|
||
feedAnswer(answerStr) {
|
||
if (!this._pendingPeer || this._pendingPeer.signalReceived) return;
|
||
try {
|
||
const data = JSON.parse(answerStr);
|
||
this._pendingPeer.peerId = data.id;
|
||
this._pendingPeer.peer.signal(data.s);
|
||
this._pendingPeer.signalReceived = true;
|
||
} catch (e) {
|
||
console.error('feedAnswer error:', e);
|
||
}
|
||
}
|
||
|
||
/* ─── Peer: join ─── */
|
||
|
||
joinFromQR(qrContent) {
|
||
const data = JSON.parse(qrContent);
|
||
const remoteId = data.id;
|
||
this.hostPeerId = remoteId;
|
||
const offer = data.s;
|
||
|
||
const peer = new SimplePeer({ initiator: false, trickle: false, config: { iceServers: [] } });
|
||
let answerSent = false;
|
||
|
||
peer.on('signal', signal => {
|
||
if (answerSent) return;
|
||
answerSent = true;
|
||
const answerData = JSON.stringify({
|
||
v: 1, id: this.myPeerId, s: signal, to: remoteId
|
||
});
|
||
if (this.onanswerready) this.onanswerready(answerData);
|
||
});
|
||
|
||
peer.on('connect', () => {
|
||
this.connections.set(remoteId, peer);
|
||
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||
if (this.onpeerconnect) this.onpeerconnect(remoteId);
|
||
});
|
||
|
||
peer.on('data', data => {
|
||
try {
|
||
const msg = JSON.parse(data.toString());
|
||
this._handleMessage(msg, peer);
|
||
} catch (e) { console.error('p2p data error:', e); }
|
||
});
|
||
|
||
peer.on('close', () => this._cleanupPeer(peer));
|
||
peer.on('error', err => console.error('p2p error:', err));
|
||
|
||
peer.signal(offer);
|
||
this.connections.set(remoteId, peer);
|
||
this._pendingPeer = { peer, peerId: remoteId, connected: answerSent };
|
||
}
|
||
|
||
joinFromRoomCode(code) {
|
||
const key = `rp2p_offer_${code.toUpperCase()}`;
|
||
const offerStr = localStorage.getItem(key);
|
||
if (!offerStr) {
|
||
console.error('Room not found:', code);
|
||
return;
|
||
}
|
||
const offerData = JSON.parse(offerStr);
|
||
const remoteId = offerData.id;
|
||
this.hostPeerId = remoteId;
|
||
const offer = offerData.s;
|
||
|
||
const peer = new SimplePeer({ initiator: false, trickle: false, config: { iceServers: [] } });
|
||
let answerSent = false;
|
||
|
||
peer.on('signal', signal => {
|
||
if (answerSent) return;
|
||
answerSent = true;
|
||
const answerKey = `rp2p_answer_${code.toUpperCase()}_${this.myPeerId}`;
|
||
localStorage.setItem(answerKey, JSON.stringify({ id: this.myPeerId, s: signal }));
|
||
});
|
||
|
||
peer.on('connect', () => {
|
||
this.connections.set(remoteId, peer);
|
||
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
|
||
if (this.onpeerconnect) this.onpeerconnect(remoteId);
|
||
});
|
||
|
||
peer.on('data', data => {
|
||
try {
|
||
const msg = JSON.parse(data.toString());
|
||
this._handleMessage(msg, peer);
|
||
} catch (e) { console.error('p2p data error:', e); }
|
||
});
|
||
|
||
peer.on('close', () => this._cleanupPeer(peer));
|
||
peer.on('error', err => console.error('p2p error:', err));
|
||
|
||
peer.signal(offer);
|
||
this.connections.set(remoteId, peer);
|
||
this._pendingPeer = { peer, peerId: remoteId, connected: answerSent };
|
||
}
|
||
|
||
/* ─── Mesh helpers ─── */
|
||
|
||
_createSimplePeer(initiator, onSignal, onConnect) {
|
||
const peer = new SimplePeer({ initiator, trickle: false, config: { iceServers: [] } });
|
||
|
||
peer.on('signal', signal => {
|
||
if (onSignal) onSignal(signal);
|
||
});
|
||
|
||
peer.on('connect', () => {
|
||
if (onConnect) onConnect();
|
||
// No identity exchange – peerId known from context
|
||
});
|
||
|
||
peer.on('data', data => {
|
||
try {
|
||
const msg = JSON.parse(data.toString());
|
||
this._handleMessage(msg, peer);
|
||
} catch (e) { console.error('mesh data error:', e); }
|
||
});
|
||
|
||
peer.on('close', () => this._cleanupPeer(peer));
|
||
peer.on('error', e => console.error('mesh error:', e));
|
||
|
||
return peer;
|
||
}
|
||
|
||
_initiateMeshConnection(targetPeerId) {
|
||
if (this.connections.has(targetPeerId)) return;
|
||
if (this._pendingMeshPeers.has(targetPeerId)) return;
|
||
if (this.myPeerId >= targetPeerId) return;
|
||
|
||
const peer = this._createSimplePeer(true,
|
||
signal => {
|
||
this.sendTo(this.hostPeerId, { type: 'signal_relay', to: targetPeerId, signal });
|
||
},
|
||
() => {
|
||
this.connections.set(targetPeerId, peer);
|
||
this._pendingMeshPeers.delete(targetPeerId);
|
||
if (this.onpeerconnect) this.onpeerconnect(targetPeerId);
|
||
this._checkMeshReady();
|
||
}
|
||
);
|
||
|
||
this._pendingMeshPeers.set(targetPeerId, peer);
|
||
}
|
||
|
||
_handleRelayedSignal(fromPeerId, signal) {
|
||
let peer = this.connections.get(fromPeerId) || this._pendingMeshPeers.get(fromPeerId);
|
||
|
||
if (!peer) {
|
||
peer = this._createSimplePeer(false,
|
||
signal => {
|
||
this.sendTo(this.hostPeerId, { type: 'signal_relay', to: fromPeerId, signal });
|
||
},
|
||
() => {
|
||
this.connections.set(fromPeerId, peer);
|
||
this._pendingMeshPeers.delete(fromPeerId);
|
||
if (this.onpeerconnect) this.onpeerconnect(fromPeerId);
|
||
this._checkMeshReady();
|
||
}
|
||
);
|
||
this._pendingMeshPeers.set(fromPeerId, peer);
|
||
}
|
||
|
||
peer.signal(signal);
|
||
}
|
||
|
||
_checkMeshConnections() {
|
||
for (const pid of this.roster) {
|
||
if (pid === this.myPeerId) continue;
|
||
if (this.connections.has(pid)) continue;
|
||
if (this.myPeerId < pid) {
|
||
this._initiateMeshConnection(pid);
|
||
}
|
||
}
|
||
this._checkMeshReady();
|
||
}
|
||
|
||
_checkMeshReady() {
|
||
if (this._meshReadySent) return;
|
||
if (!this.hostPeerId) return;
|
||
if (!this.connections.has(this.hostPeerId)) return;
|
||
|
||
const allConnected = this.roster.every(pid =>
|
||
pid === this.myPeerId || this.connections.has(pid)
|
||
);
|
||
|
||
if (allConnected && this.roster.length > 0) {
|
||
this._meshReadySent = true;
|
||
this.sendTo(this.hostPeerId, { type: 'mesh_ready' });
|
||
}
|
||
}
|
||
|
||
_broadcastRoster() {
|
||
this.roster = this.getPeerIds();
|
||
this.readyPeers.clear();
|
||
this.broadcast({ type: 'roster', peers: this.roster });
|
||
if (this.onreadyupdate) this.onreadyupdate(0, this.roster.length);
|
||
}
|
||
|
||
/* ─── Message routing ─── */
|
||
|
||
_handleMessage(msg, peer) {
|
||
const peerId = this._findPeerId(peer) || 'unknown';
|
||
|
||
if (msg.type === 'identity') {
|
||
const existing = this._findPeerId(peer);
|
||
if (existing && existing !== msg.peerId) {
|
||
this.connections.delete(existing);
|
||
}
|
||
this.connections.set(msg.peerId, peer);
|
||
|
||
if (this._pendingPeer && this._pendingPeer.peer === peer) {
|
||
this._pendingPeer.peerId = msg.peerId;
|
||
this._pendingPeer.connected = true;
|
||
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||
if (this.isHost) {
|
||
setTimeout(() => this._createPendingPeer(), 300);
|
||
this._broadcastRoster();
|
||
}
|
||
} else {
|
||
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'signal_relay') {
|
||
const target = this.connections.get(msg.to) || this._pendingMeshPeers.get(msg.to);
|
||
if (target) {
|
||
target.send(JSON.stringify({
|
||
type: 'signal_relayed',
|
||
from: peerId,
|
||
signal: msg.signal
|
||
}));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'signal_relayed') {
|
||
this._handleRelayedSignal(msg.from, msg.signal);
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'roster') {
|
||
this.roster = msg.peers || [];
|
||
this._meshReadySent = false;
|
||
this._checkMeshConnections();
|
||
return;
|
||
}
|
||
|
||
if (msg.type === 'mesh_ready' && this.isHost) {
|
||
this.readyPeers.add(peerId);
|
||
if (this.onreadyupdate) {
|
||
this.onreadyupdate(this.readyPeers.size, this.roster.length);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (this.ondata) {
|
||
this.ondata(peerId, msg);
|
||
}
|
||
}
|
||
|
||
/* ─── Peer management ─── */
|
||
|
||
_findPeerId(peer) {
|
||
for (const [id, p] of this.connections) {
|
||
if (p === peer) return id;
|
||
}
|
||
for (const [id, p] of this._pendingMeshPeers) {
|
||
if (p === peer) return id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_cleanupPeer(peer) {
|
||
const peerId = this._findPeerId(peer);
|
||
if (peerId) {
|
||
this.connections.delete(peerId);
|
||
if (this.onpeerdisconnect) this.onpeerdisconnect(peerId);
|
||
}
|
||
|
||
for (const [id, p] of this._pendingMeshPeers) {
|
||
if (p === peer) {
|
||
this._pendingMeshPeers.delete(id);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (this._pendingPeer && this._pendingPeer.peer === peer) {
|
||
this._pendingPeer = null;
|
||
}
|
||
}
|
||
|
||
/* ─── Send ─── */
|
||
|
||
broadcast(msg) {
|
||
const str = JSON.stringify(msg);
|
||
for (const [id, peer] of this.connections) {
|
||
try { peer.send(str); } catch (e) { console.error('broadcast to', id, 'failed:', e); }
|
||
}
|
||
}
|
||
|
||
sendTo(peerId, msg) {
|
||
const peer = this.connections.get(peerId);
|
||
if (peer) {
|
||
try { peer.send(JSON.stringify(msg)); } catch (e) { console.error('sendTo', peerId, 'failed:', e); }
|
||
}
|
||
}
|
||
|
||
relaySignal(from, to, signal) {
|
||
const target = this.connections.get(to);
|
||
if (target) {
|
||
target.send(JSON.stringify({ type: 'signal_relay', from, to, signal }));
|
||
}
|
||
}
|
||
|
||
getPeerIds() {
|
||
return Array.from(this.connections.keys());
|
||
}
|
||
|
||
getCount() {
|
||
return this.connections.size;
|
||
}
|
||
|
||
getFullSDPData() {
|
||
return this._fullSDPData;
|
||
}
|
||
|
||
destroy() {
|
||
this._stopAnswerPolling();
|
||
for (const peer of this.connections.values()) {
|
||
try { peer.destroy(); } catch (e) {}
|
||
}
|
||
for (const peer of this._pendingMeshPeers.values()) {
|
||
try { peer.destroy(); } catch (e) {}
|
||
}
|
||
this.connections.clear();
|
||
this._pendingMeshPeers.clear();
|
||
if (this._pendingPeer) {
|
||
try { this._pendingPeer.peer.destroy(); } catch (e) {}
|
||
this._pendingPeer = null;
|
||
}
|
||
}
|
||
}
|