onilib
Version:
A modular Node.js library for real-time online integration in games and web applications
524 lines (429 loc) • 14 kB
JavaScript
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 };