UNPKG

tiny-server-essentials

Version:

A good utility toolkit to unify Express v5 and Socket.IO v4 into a seamless development experience with modular helpers, server wrappers, and WebSocket tools.

519 lines (447 loc) 15.7 kB
'use strict'; var EventEmitter = require('events'); var socket_io = require('socket.io'); var tinyEssentials = require('tiny-essentials'); /** * @typedef {Object} ProxyUserDisconnect * Represents a disconnection report sent to the proxy server. * * @property {string} id - Disconnected user socket ID. * @property {import('socket.io').DisconnectReason} reason - Reason provided by Socket.IO. * @property {any} desc - Additional description or error object. */ /** * @typedef {Object} ProxyUserConnectionUpdated * Represents a diff patch of updated user connection data. * * @property {string} id - User socket ID. * @property {ProxyUserConnection} changes - Only changed fields. */ /** * @typedef {Object} ProxyUserConnection * Represents all serializable data of a connected socket. * This object is transmitted to the proxy server so it can simulate * remote access to all user details without having direct access to the runtime socket. * * This enables: * - fully remote introspection * - remote user operations * - remote room joins/leaves * - remote event emission * * @property {string} id - Unique socket identifier. * * @property {string[]} rooms - All rooms the socket currently belongs to. * * @property {Object} handshake - Full handshake information sent by the client. * @property {string} handshake.address - Client IP address. * @property {Object.<string, any>} handshake.headers - HTTP headers from the client request. * @property {Object.<string, any>} handshake.query - Incoming connection query-string params. * @property {string} handshake.time - Connection timestamp. * @property {boolean} handshake.secure - Whether the handshake occurred over HTTPS/WSS. * @property {boolean} handshake.xdomain - Whether the request originated from another domain. * @property {number} handshake.issued - Unix timestamp for handshake issuance. * @property {string} handshake.url - Full request URL. * * @property {Object} engine - Safe subset of Engine.IO internal metadata. * @property {string} engine.readyState - Engine.IO connection state. * @property {string} engine.transport - Current transport ("polling" or "websocket"). * @property {number} engine.protocol - Engine.IO protocol version. * * @property {string} namespace - Namespace the client is connected to. */ /** * @typedef {[id: string, ...import('socket.io').Event]} ProxyRequest */ /** * SocketIoProxyServer * * This class creates a **remote control layer** on top of a Socket.IO server. * * It exposes: * - A **single authenticated controlling socket** (the operator) * - All connected users with full serializable metadata * - Automatic change detection via diffing * - Remote room operations (join, leave) * - Remote broadcasting * - Remote event emission * - Proxied user events with return-path back to the operator * * It is effectively a "virtualized mirror" of all socket activity, * allowing external systems to observe and interact with real users * without having direct access to the socket server runtime. * * @beta */ class SocketIoProxyServer extends EventEmitter.EventEmitter { #isDestroyed = false; /** @returns {boolean} */ get isDestroyed() { return this.#isDestroyed; } /** @type {null|import('socket.io').Socket} */ #socket = null; /** @type {import('socket.io').Server|null} */ #server; /** @returns {null|import('socket.io').Socket} */ get socket() { return this.#socket; } /** @returns {import('socket.io').Server} */ get server() { if (this.#isDestroyed || !this.#server) throw new Error('Instance destroyed!'); return this.#server; } /** @type {null|string|number} */ #auth = null; /** * Sets the authentication value required by the operator client. * Only one socket is allowed to become the proxy operator. * * @param {null|string|number} value - Authentication secret. */ set auth(value) { if (typeof value !== 'string' && typeof value !== 'number' && value !== null) throw new Error('Invalid Server Auth!'); this.#auth = value; } /** @type {null|number} */ #connTimeout = null; /** * Sets the maximum allowed time (ms) before a newly connected socket * must authenticate as the proxy operator. * If authentication does not occur in time, the socket is forcibly disconnected. * * @param {null|number} value */ set connTimeout(value) { if (typeof value !== 'number' && value !== null) throw new Error('Invalid connection timeout!'); this.#connTimeout = value; } /** @returns {null|number} */ get connTimeout() { return this.#connTimeout; } /** * Extracts all safe, serializable information from a Socket.IO socket instance. * Used for: * - initial connection sync * - remote inspection * - diff computation * * @param {import('socket.io').Socket} socket * @returns {ProxyUserConnection} */ extractSocketInfo(socket) { return { id: socket.id, // Rooms this client belongs to rooms: [...socket.rooms], // Handshake data (⚠ contains headers, query) handshake: { address: socket.handshake.address, headers: socket.handshake.headers, query: socket.handshake.query, time: socket.handshake.time, secure: socket.handshake.secure, xdomain: socket.handshake.xdomain, issued: socket.handshake.issued, url: socket.handshake.url, }, // Engine.IO internal data (serializable only!) engine: { readyState: socket.conn.readyState, transport: socket.conn.transport.name, protocol: socket.conn.protocol, }, // Namespace namespace: socket.nsp.name, }; } /** * Computes a deep diff between two ProxyUserConnection objects. * Only returns changed fields, keeping full structure where needed. * * @param {ProxyUserConnection} oldData * @param {ProxyUserConnection} newData * @returns {ProxyUserConnection|null} - Diff or null if nothing changed. */ #diff(oldData, newData) { const diff = oldData; let hasChanges = false; for (const key of Object.keys(newData)) { // @ts-ignore const oldVal = oldData[key]; // @ts-ignore const newVal = newData[key]; if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { // @ts-ignore diff[key] = newVal; hasChanges = true; } } return hasChanges ? diff : null; } /** * Emits an update event and sends delta-changes to the proxy operator. * Triggered when: * - user joins/leaves room * - connection upgrades * - transport changes * - handshake changes * * @param {import('socket.io').Socket} socket * @param {string} type - Update category. * @param {string|null} [room] - Room affected (if any). */ #emitUpdate(socket, type, room) { if (!this.#socket) return; const current = this.extractSocketInfo(socket); const previous = this.#socketStates.get(socket.id) ?? current; const changes = this.#diff(previous, current); if (!changes) return; this.#socketStates.set(socket.id, current); /** @type {ProxyUserConnectionUpdated} */ const result = { id: socket.id, changes }; this.emit(`user-update`, socket, type, room); this.#socket.emit('PROXY_USER_UPDATE', result, type, room); } /** * Sends full state for a newly connected user to the operator. * @param {import('socket.io').Socket} socket */ #sendNewUser(socket) { if (!this.#socket) return; const initial = this.extractSocketInfo(socket); this.#socketStates.set(socket.id, initial); this.#socket.emit('PROXY_USER_CONNECTION', initial); } /** @type {Map<string, import('socket.io').Socket>} */ #sockets = new Map(); /** @type {Map<string, ProxyUserConnection>} */ #socketStates = new Map(); /** * Returns an object representation of all active sockets. * Key = socket.id * Value = socket instance * * @returns {Record<string, import('socket.io').Socket>} */ get sockets() { return Object.fromEntries(this.#sockets); } /** * Hooks into the adapter layer of a specific namespace, allowing detection of: * - join-room * - leave-room * * This ensures room-level changes emit diffs to the proxy operator. * * @param {string} where - Namespace path. */ listenAdapter(where) { if (!this.#server) throw new Error('No server detected!'); const adapter = this.#server.of(where).adapter; adapter.on('join-room', (room, id) => { const userSocket = this.#sockets.get(id); if (!userSocket) return; this.#emitUpdate(userSocket, 'join-room', room); }); adapter.on('leave-room', (room, id) => { const userSocket = this.#sockets.get(id); if (!userSocket) return; this.#emitUpdate(userSocket, 'leave-room', room); }); return adapter; } /** * Creates a full Socket.IO proxy server instance. * * @param {import('socket.io').ServerOptions} proxyCfg - Socket.IO server config. * @param {Object} [rlCfg] - Rate limiter settings. * @param {number} [rlCfg.maxHits=3] - Maximum hits before blocking. * @param {number} [rlCfg.interval=1000] - Rate limiter interval. * @param {number} [rlCfg.cleanupInterval=60000] - Cleanup frequency. */ constructor(proxyCfg, { maxHits = 3, interval = 1000, cleanupInterval = 60000 } = {}) { super(); this.#server = new socket_io.Server(proxyCfg); const authRl = new tinyEssentials.TinyRateLimiter({ maxHits, interval, cleanupInterval }); // ---- Main connection logic ---- this.#server.on('connection', (userSocket) => { // Data /** const dataProxy = new Proxy(userSocket.data, { set: (obj, prop, value) => { obj[prop] = value; this.#emitUpdate(userSocket, 'data'); return true; }, }); userSocket.data = dataProxy; */ // Set socket data this.#sockets.set(userSocket.id, userSocket); this.#sendNewUser(userSocket); // Start connection this.emit('connection', userSocket); // Timeout /** @type {NodeJS.Timeout|null} */ let timeoutConnection = !this.#socket ? setTimeout(() => { this.emit('connection-timeout', userSocket); userSocket.disconnect(true); }, this.#connTimeout ?? 0) : null; const removeTimeout = () => { if (!timeoutConnection) return; clearTimeout(timeoutConnection); timeoutConnection = null; }; // Transport upgrade userSocket.conn.on('upgrade', () => { this.#emitUpdate(userSocket, 'upgrade'); }); // Closed transport userSocket.conn.on('close', () => { this.#emitUpdate(userSocket, 'close'); }); // Pre-registered event handlers map /** @type {Map<string, (...args: any) => void>} */ const events = new Map(); // ---- Authentication event ---- events.set( 'AUTH_PROXY', (/** @type {string} */ auth, /** @type {(arg: any) => void} */ fn) => { if ( this.#socket || this.#auth !== auth || authRl.isRateLimited(userSocket.id) || typeof fn !== 'function' ) { if (this.#socket?.id === userSocket.id) return; authRl.hit(userSocket.id); userSocket.disconnect(true); removeTimeout(); return; } this.#socket = userSocket; this.emit('server-connection', userSocket); removeTimeout(); fn(!!this.#socket); // Send state of all existing users this.#sockets.forEach((socket) => this.#sendNewUser(socket)); }, ); // ---- Remote user disconnect ---- events.set('DISCONNECT_PROXY_USER', (...args) => { if ( this.#socket?.id !== userSocket.id || !tinyEssentials.isJsonObject(args[0]) || typeof args[0].id !== 'string' || typeof args[0].close !== 'boolean' ) return; const socket = this.#sockets.get(args[0].id); if (!socket) return; removeTimeout(); socket.disconnect(args[0].close); }); // ---- Remote join room ---- events.set( 'PROXY_USER_JOIN', ( /** @type {{ id: string; room: string; }} */ data, /** @type {(arg: any) => boolean} */ fn, ) => { const { id, room } = data; const socket = this.#sockets.get(id); if (!socket) return fn(false); socket.join(room); fn(true); }, ); // ---- Remote leave room ---- events.set( 'PROXY_USER_LEAVE', ( /** @type {{ id: string; room: string; }} */ data, /** @type {(arg: any) => boolean} */ fn, ) => { const { id, room } = data; const socket = this.#sockets.get(id); if (!socket) return fn(false); socket.leave(room); fn(true); }, ); // ---- Remote broadcast from specific user ---- events.set('PROXY_USER_BROADCAST_OPERATOR', (id, room, eventName, ...args) => { const socket = this.#sockets.get(id); if (!socket) return args[args.length - 1](); socket.to(room).emit(eventName, ...args); }); // ---- Remote broadcast from proxy server ---- events.set('PROXY_BROADCAST_OPERATOR', (room, eventName, ...args) => { if (this.#server) this.#server.to(room).emit(eventName, ...args); }); // ---- Remote emit directly to user ---- events.set('PROXY_EMIT', (id, eventName, ...args) => { const socket = this.#sockets.get(id); if (!socket) return args[args.length - 1](); socket.emit(eventName, ...args); }); // ---- Generic user events ---- userSocket.onAny((eventName, ...args) => { this.emit('user-event', userSocket, eventName, [...args]); // Custom Event const eventFn = events.get(eventName); if (eventFn) return eventFn(...args); // No server if (!this.#socket) { userSocket.disconnect(true); removeTimeout(); return; } removeTimeout(); /** @type {ProxyRequest} */ const data = [userSocket.id, eventName, ...args]; this.#socket.emit('PROXY_REQUEST', ...data); }); // ---- On disconnect ---- userSocket.on('disconnect', (reason, desc) => { this.#sockets.delete(userSocket.id); this.#socketStates.delete(userSocket.id); if (this.#socket) { /** @type {ProxyUserDisconnect} */ const data = { id: userSocket.id, reason, desc }; this.#socket.emit('PROXY_USER_DISCONNECT', data); } if (this.#socket?.id === userSocket.id) { this.#socket = null; this.emit('server-disconnect', userSocket); return; } this.emit('disconnect', userSocket); removeTimeout(); }); }); } /** * Fully destroys the proxy server, closing the Socket.IO instance * and clearing all tracked state from memory. */ destroy() { this.#server?.close(); this.#server?.removeAllListeners(); this.#sockets.clear(); this.#socketStates.clear(); this.#socket = null; this.#auth = null; this.#connTimeout = null; this.#isDestroyed = true; this.#server = null; this.removeAllListeners(); } } module.exports = SocketIoProxyServer;