randomp2p/p2p.js

468 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, _retries = 10) {
const key = `rp2p_offer_${code.toUpperCase()}`;
const offerStr = localStorage.getItem(key);
if (!offerStr) {
if (_retries > 0) {
setTimeout(() => this.joinFromRoomCode(code, _retries - 1), 500);
}
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;
}
}
}