UNPKG

onilib

Version:

A modular Node.js library for real-time online integration in games and web applications

524 lines (429 loc) 14 kB
const { v4: uuidv4 } = require('uuid'); const EventEmitter = require('events'); class P2PModule extends EventEmitter { constructor(config = {}) { super(); this.config = { enableRelay: config.enableRelay || false, maxPeersPerRoom: config.maxPeersPerRoom || 8, signalingTimeout: config.signalingTimeout || 30000, iceServers: config.iceServers || [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ], rateLimiting: { maxSignalsPerSecond: config.rateLimiting?.maxSignalsPerSecond || 10, windowMs: config.rateLimiting?.windowMs || 1000 }, ...config }; this.peers = new Map(); this.rooms = new Map(); this.signalingRateLimit = new Map(); this.cleanupInterval = null; } async initialize(noi) { this.noi = noi; this.core = noi.core; this.realtime = noi.getModule('realtime'); if (!this.realtime) { throw new Error('P2P module requires realtime module'); } // Register WebRTC signaling handlers this.realtime.registerHandler('p2p_join_room', this.handleJoinP2PRoom.bind(this)); this.realtime.registerHandler('p2p_leave_room', this.handleLeaveP2PRoom.bind(this)); this.realtime.registerHandler('p2p_signal', this.handleSignal.bind(this)); this.realtime.registerHandler('p2p_ice_candidate', this.handleIceCandidate.bind(this)); this.realtime.registerHandler('p2p_offer', this.handleOffer.bind(this)); this.realtime.registerHandler('p2p_answer', this.handleAnswer.bind(this)); // Start cleanup interval this.startCleanupInterval(); this.core.log('info', 'P2P module initialized'); } async handleJoinP2PRoom(client, message) { const { roomId, peerConfig = {} } = message.data; try { if (!roomId) { throw new Error('Room ID is required'); } const peer = await this.addPeerToRoom(client, roomId, peerConfig); this.realtime.sendToClient(client, { type: 'p2p_room_joined', data: { roomId, peerId: peer.id, iceServers: this.config.iceServers, existingPeers: this.getRoomPeers(roomId).filter(p => p.id !== peer.id) } }); // Notify existing peers about new peer this.broadcastToRoom(roomId, { type: 'p2p_peer_joined', data: { peerId: peer.id, peerConfig: peer.config } }, client.id); } catch (error) { this.realtime.sendError(client, `Failed to join P2P room: ${error.message}`); } } async handleLeaveP2PRoom(client, message) { const { roomId } = message.data; try { await this.removePeerFromRoom(client.id, roomId); this.realtime.sendToClient(client, { type: 'p2p_room_left', data: { roomId } }); } catch (error) { this.realtime.sendError(client, `Failed to leave P2P room: ${error.message}`); } } async handleSignal(client, message) { const { targetPeerId, signal, roomId } = message.data; try { if (!this.checkRateLimit(client.id)) { throw new Error('Rate limit exceeded'); } const targetPeer = this.peers.get(targetPeerId); if (!targetPeer) { throw new Error('Target peer not found'); } // Verify both peers are in the same room const senderPeer = this.peers.get(client.id); if (!senderPeer || !senderPeer.rooms.has(roomId) || !targetPeer.rooms.has(roomId)) { throw new Error('Peers are not in the same room'); } this.realtime.sendToClient(targetPeer.client, { type: 'p2p_signal', data: { fromPeerId: client.id, signal, roomId } }); this.emit('signal:relayed', { from: client.id, to: targetPeerId, roomId, signal }); } catch (error) { this.realtime.sendError(client, `Failed to relay signal: ${error.message}`); } } async handleOffer(client, message) { const { targetPeerId, offer, roomId } = message.data; try { if (!this.checkRateLimit(client.id)) { throw new Error('Rate limit exceeded'); } const targetPeer = this.peers.get(targetPeerId); if (!targetPeer) { throw new Error('Target peer not found'); } const senderPeer = this.peers.get(client.id); if (!senderPeer || !senderPeer.rooms.has(roomId) || !targetPeer.rooms.has(roomId)) { throw new Error('Peers are not in the same room'); } this.realtime.sendToClient(targetPeer.client, { type: 'p2p_offer', data: { fromPeerId: client.id, offer, roomId } }); this.core.log('debug', `WebRTC offer relayed from ${client.id} to ${targetPeerId}`); } catch (error) { this.realtime.sendError(client, `Failed to relay offer: ${error.message}`); } } async handleAnswer(client, message) { const { targetPeerId, answer, roomId } = message.data; try { if (!this.checkRateLimit(client.id)) { throw new Error('Rate limit exceeded'); } const targetPeer = this.peers.get(targetPeerId); if (!targetPeer) { throw new Error('Target peer not found'); } const senderPeer = this.peers.get(client.id); if (!senderPeer || !senderPeer.rooms.has(roomId) || !targetPeer.rooms.has(roomId)) { throw new Error('Peers are not in the same room'); } this.realtime.sendToClient(targetPeer.client, { type: 'p2p_answer', data: { fromPeerId: client.id, answer, roomId } }); this.core.log('debug', `WebRTC answer relayed from ${client.id} to ${targetPeerId}`); } catch (error) { this.realtime.sendError(client, `Failed to relay answer: ${error.message}`); } } async handleIceCandidate(client, message) { const { targetPeerId, candidate, roomId } = message.data; try { if (!this.checkRateLimit(client.id)) { throw new Error('Rate limit exceeded'); } const targetPeer = this.peers.get(targetPeerId); if (!targetPeer) { throw new Error('Target peer not found'); } const senderPeer = this.peers.get(client.id); if (!senderPeer || !senderPeer.rooms.has(roomId) || !targetPeer.rooms.has(roomId)) { throw new Error('Peers are not in the same room'); } this.realtime.sendToClient(targetPeer.client, { type: 'p2p_ice_candidate', data: { fromPeerId: client.id, candidate, roomId } }); } catch (error) { this.realtime.sendError(client, `Failed to relay ICE candidate: ${error.message}`); } } async addPeerToRoom(client, roomId, peerConfig = {}) { // Check room capacity const room = this.rooms.get(roomId); if (room && room.peers.size >= this.config.maxPeersPerRoom) { throw new Error('Room is full'); } // Remove peer from any existing rooms await this.removePeerFromAllRooms(client.id); // Create or get room if (!this.rooms.has(roomId)) { this.rooms.set(roomId, { id: roomId, peers: new Set(), createdAt: Date.now(), metadata: {} }); } // Create peer object const peer = { id: client.id, client, rooms: new Set([roomId]), config: peerConfig, joinedAt: Date.now(), connections: new Map(), // Track P2P connections lastActivity: Date.now() }; this.peers.set(client.id, peer); this.rooms.get(roomId).peers.add(client.id); this.core.log('debug', `Peer ${client.id} joined P2P room ${roomId}`); this.emit('peer:joined_room', { peer, roomId }); return peer; } async removePeerFromRoom(peerId, roomId) { const peer = this.peers.get(peerId); const room = this.rooms.get(roomId); if (!peer || !room) { return false; } room.peers.delete(peerId); peer.rooms.delete(roomId); // Notify other peers this.broadcastToRoom(roomId, { type: 'p2p_peer_left', data: { peerId, roomId } }); // Remove empty rooms if (room.peers.size === 0) { this.rooms.delete(roomId); } // Remove peer if not in any rooms if (peer.rooms.size === 0) { this.peers.delete(peerId); } this.core.log('debug', `Peer ${peerId} left P2P room ${roomId}`); this.emit('peer:left_room', { peerId, roomId }); return true; } async removePeerFromAllRooms(peerId) { const peer = this.peers.get(peerId); if (!peer) return; const roomIds = Array.from(peer.rooms); for (const roomId of roomIds) { await this.removePeerFromRoom(peerId, roomId); } } broadcastToRoom(roomId, message, excludePeerId = null) { const room = this.rooms.get(roomId); if (!room) return; for (const peerId of room.peers) { if (peerId !== excludePeerId) { const peer = this.peers.get(peerId); if (peer && this.realtime) { this.realtime.sendToClient(peer.client, message); } } } } getRoomPeers(roomId) { const room = this.rooms.get(roomId); if (!room) return []; return Array.from(room.peers) .map(peerId => this.peers.get(peerId)) .filter(peer => peer) .map(peer => ({ id: peer.id, config: peer.config, joinedAt: peer.joinedAt })); } checkRateLimit(peerId) { const now = Date.now(); const windowStart = now - this.config.rateLimiting.windowMs; if (!this.signalingRateLimit.has(peerId)) { this.signalingRateLimit.set(peerId, []); } const requests = this.signalingRateLimit.get(peerId); // Remove old requests outside the window const validRequests = requests.filter(timestamp => timestamp > windowStart); // Check if under limit if (validRequests.length >= this.config.rateLimiting.maxSignalsPerSecond) { return false; } // Add current request validRequests.push(now); this.signalingRateLimit.set(peerId, validRequests); return true; } startCleanupInterval() { this.cleanupInterval = setInterval(() => { this.cleanupInactivePeers(); this.cleanupRateLimit(); }, 60000); // Every minute } cleanupInactivePeers() { const now = Date.now(); const inactivityTimeout = 300000; // 5 minutes for (const [peerId, peer] of this.peers) { if (now - peer.lastActivity > inactivityTimeout) { this.core.log('debug', `Removing inactive peer: ${peerId}`); this.removePeerFromAllRooms(peerId); } } } cleanupRateLimit() { const now = Date.now(); const windowStart = now - this.config.rateLimiting.windowMs; for (const [peerId, requests] of this.signalingRateLimit) { const validRequests = requests.filter(timestamp => timestamp > windowStart); if (validRequests.length === 0) { this.signalingRateLimit.delete(peerId); } else { this.signalingRateLimit.set(peerId, validRequests); } } } // Relay server functionality (optional) async enableRelay(peer1Id, peer2Id, roomId) { if (!this.config.enableRelay) { throw new Error('Relay is not enabled'); } const peer1 = this.peers.get(peer1Id); const peer2 = this.peers.get(peer2Id); if (!peer1 || !peer2) { throw new Error('One or both peers not found'); } // Create relay connection const relayId = uuidv4(); const relay = { id: relayId, peer1: peer1Id, peer2: peer2Id, roomId, createdAt: Date.now(), bytesRelayed: 0 }; // Notify peers about relay this.realtime.sendToClient(peer1.client, { type: 'p2p_relay_enabled', data: { relayId, targetPeerId: peer2Id, roomId } }); this.realtime.sendToClient(peer2.client, { type: 'p2p_relay_enabled', data: { relayId, targetPeerId: peer1Id, roomId } }); this.core.log('info', `Relay enabled between ${peer1Id} and ${peer2Id}`); this.emit('relay:enabled', relay); return relay; } getStats() { const roomStats = {}; for (const [roomId, room] of this.rooms) { roomStats[roomId] = { peerCount: room.peers.size, createdAt: room.createdAt }; } return { totalPeers: this.peers.size, totalRooms: this.rooms.size, roomStats, rateLimitEntries: this.signalingRateLimit.size }; } getRooms() { return Array.from(this.rooms.values()).map(room => ({ id: room.id, peerCount: room.peers.size, createdAt: room.createdAt, peers: Array.from(room.peers) })); } getPeers() { return Array.from(this.peers.values()).map(peer => ({ id: peer.id, rooms: Array.from(peer.rooms), joinedAt: peer.joinedAt, lastActivity: peer.lastActivity, connectionCount: peer.connections.size })); } async stop() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } // Notify all peers about shutdown for (const peer of this.peers.values()) { if (this.realtime) { this.realtime.sendToClient(peer.client, { type: 'p2p_shutdown', data: { reason: 'Server shutting down' } }); } } this.peers.clear(); this.rooms.clear(); this.signalingRateLimit.clear(); this.core.log('info', 'P2P module stopped'); } } module.exports = { P2PModule };