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.
333 lines (297 loc) • 9.17 kB
JavaScript
'use strict';
var EventEmitter = require('events');
var tinyEssentials = require('tiny-essentials');
var proxyArgs = require('./proxyArgs.cjs');
/** @typedef {import('../server/index.mjs').ProxyUserConnection} ProxyUserConnection */
/**
* Represents a proxied Socket.IO user inside the architecture.
*
* This class mirrors a remote user connection received from the proxy layer and exposes
* an API very similar to a regular Socket.IO `Socket` instance, but all actions
* (emit, join, leave, broadcast, disconnect) are routed through the proxy operator.
*
* The purpose of this class is:
* - Keep an internal synchronized representation of the remote user's socket
* - Provide a safe and validated interface for broadcasting events or modifying the user's state
* - Emit local events notifying about remote room-joins, room-leaves, and metadata updates
*
* Internally, the class maintains snapshots of:
* - Rooms
* - Handshake data
* - Transport and Engine.IO details
* - Namespace information
*
* These are updated whenever the proxy sends new data.
*
* @beta
*/
class SocketIoProxyUser extends EventEmitter {
/** @type {EventEmitter} Internal event emitter for per-user system events */
#userConn = new EventEmitter();
/** @returns {EventEmitter} Exposes internal user event stream */
get userConn() {
return this.#userConn;
}
/** @type {Record<string|number|symbol, any>} Arbitrary metadata container */
#data = {};
/** @returns {Record<string|number|symbol, any>} Returns stored custom metadata */
get data() {
return this.#data;
}
/**
* Overwrites metadata with a new JSON-compatible object.
* @param {Record<string|number|symbol, any>} value
*/
set data(value) {
if (!tinyEssentials.isJsonObject(value)) throw new Error('Invalid data object type!');
this.#data = value;
}
/** @type {boolean} Connection state tracking */
#connected = true;
#disconnected = false;
/**
* Remote socket identifier provided by the proxy.
* @type {string}
*/
#id = '';
/**
* Set of rooms the proxied user belongs to.
* @type {Set<string>}
*/
#rooms = new Set();
/**
* Engine.IO transport details (safe subset).
*/
#conn = {
/**
* Current transport used by the client
*/
transport: {
/** @type {string} Current transport being used */
name: '',
},
/** @type {string} Current readyState of the transport */
readyState: '',
/** @type {number} Engine.IO protocol version */
protocol: NaN,
};
/**
* Handshake information mirrored from the proxy.
*/
#handshake = {
/** @type {Object.<string, string>} Request headers */
headers: {},
/** @type {Object.<string, any>} Query parameters */
query: {},
/** @type {string} Human-readable connection time */
time: '',
/** @type {boolean} Whether the connection is secure */
secure: false,
/** @type {boolean} Whether the client is from another domain */
xdomain: false,
/** @type {number} Timestamp of handshake issuance */
issued: NaN,
/** @type {string} Full request URL */
url: '',
/** @type {string} Client IP address */
address: '',
};
/**
* Namespace information of the proxied user.
*/
#nsp = {
/** @type {string} Namespace name */
name: '',
};
/**
* Returns a proxy-adjusted unique id (local socket id + remote id).
* @returns {string}
*/
get id() {
return `${this.#socket.id}_PROXY_${this.#id}`;
}
/** @returns {boolean} Whether the proxied user is currently connected */
get connected() {
return this.#connected;
}
/** @returns {boolean} Whether the proxied user is permanently disconnected */
get disconnected() {
return this.#disconnected;
}
/**
* Returns a cloned list of rooms.
* @returns {string[]}
*/
get rooms() {
return Array.from(this.#rooms);
}
/**
* Returns a deep clone of Engine.IO connection details.
* @returns {{
* transport: { name: string },
* readyState: string,
* protocol: number
* }}
*/
get conn() {
return structuredClone(this.#conn);
}
/**
* Returns a deep clone of handshake information.
* @returns {{
* headers: Object.<string, string>,
* query: Object.<string, any>,
* time: string,
* secure: boolean,
* xdomain: boolean,
* issued: number,
* url: string,
* address: string
* }}
*/
get handshake() {
return structuredClone(this.#handshake);
}
/**
* Returns a cloned namespace information object.
* @returns {{ name: string }}
*/
get nsp() {
return structuredClone(this.#nsp);
}
/** @type {import('socket.io-client').Socket} Underlying Socket.IO client used to send proxy commands */
#socket;
/**
* Updates internal proxy user state and optionally emits a change event.
*
* @param {ProxyUserConnection} socketInfo Remote user state snapshot
* @param {string} [type] Optional event type to emit (e.g., "join", "leave")
* @param {string|null} [room] Room related to the update, if applicable
*/
_updateData(socketInfo, type, room) {
this.#id = socketInfo.id;
this.#rooms = new Set(socketInfo.rooms);
this.#nsp.name = socketInfo.namespace;
this.#conn.transport.name = socketInfo.engine.transport;
this.#conn.readyState = socketInfo.engine.readyState;
this.#conn.protocol = socketInfo.engine.protocol;
this.#handshake.headers = socketInfo.handshake.headers;
this.#handshake.address = socketInfo.handshake.address;
this.#handshake.query = socketInfo.handshake.query;
this.#handshake.time = socketInfo.handshake.time;
this.#handshake.secure = socketInfo.handshake.secure;
this.#handshake.xdomain = socketInfo.handshake.xdomain;
this.#handshake.issued = socketInfo.handshake.issued;
this.#handshake.url = socketInfo.handshake.url;
if (type) this.#userConn.emit(type, room);
}
/**
* Creates a new proxied user instance.
*
* @param {ProxyUserConnection} socketInfo Initial remote state snapshot
* @param {import('socket.io-client').Socket} socket Client-side operator socket
*/
constructor(socketInfo, socket) {
super();
this.#socket = socket;
this._updateData(socketInfo);
}
/**
* Broadcasts an event to one or more rooms on behalf of the proxied user.
*
* This does NOT emit locally — it sends a command to the proxy.
*
* @param {string|string[]} room Target room(s)
*/
to(room) {
return {
/**
* Emits a broadcast event to specified rooms through the proxy engine.
*
* @param {string} eventName
* @param {...any} args
* @returns {import('socket.io-client').Socket}
*/
emit: (eventName, ...args) =>
this.#socket.emit(
'PROXY_USER_BROADCAST_OPERATOR',
...proxyArgs([this.#id, room, eventName, ...args]),
),
};
}
/**
* Emits a local event inside the proxy-user instance.
* @param {string|symbol} eventName
* @param {...any} args
*/
_emit(eventName, ...args) {
return super.emit(eventName, ...args);
}
/**
* Emits an event to the proxied user's remote socket.
*
* This does NOT emit locally — it sends a proxy-level `PROXY_EMIT`.
*
* @param {string|symbol} eventName
* @param {...any} args
* @returns {boolean} Whether the emit command was accepted
*/
emit(eventName, ...args) {
return !!this.#socket.emit('PROXY_EMIT', ...proxyArgs([this.#id, eventName, ...args]));
}
/**
* Requests the remote user to join a room through the proxy.
*
* @param {string} room
* @returns {Promise<boolean>}
*/
join(room) {
return new Promise((resolve) => {
this.#socket.emit(
'PROXY_USER_JOIN',
{ id: this.#id, room },
(/** @type {boolean} */ result) => resolve(typeof result === 'boolean' ? result : false),
);
});
}
/**
* Requests the remote user to leave a room through the proxy.
*
* @param {string} room
* @returns {Promise<boolean>}
*/
leave(room) {
return new Promise((resolve) => {
this.#socket.emit(
'PROXY_USER_LEAVE',
{ id: this.#id, room },
(/** @type {boolean} */ result) => resolve(typeof result === 'boolean' ? result : false),
);
});
}
/**
* Marks the proxied user as disconnected and clears local state.
* Internal use only.
*/
_disconnect() {
if (this.#disconnected) return;
this.removeAllListeners();
this.#connected = false;
this.#disconnected = true;
this.#rooms.clear();
}
/**
* Requests the proxy to disconnect the remote user.
*
* @param {boolean} [close=false] Whether the underlying engine should also close the session
* @returns {this}
*/
disconnect(close = false) {
if (this.#disconnected) return this;
if (typeof close !== 'boolean') throw new Error('Close needs to be a boolean value!');
this.#socket.emit('DISCONNECT_PROXY_USER', { id: this.#id, close });
this._disconnect();
return this;
}
}
module.exports = SocketIoProxyUser;