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; } } }