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.
405 lines (364 loc) • 14.1 kB
JavaScript
'use strict';
var http = require('http');
var https = require('https');
var socket_io = require('socket.io');
var TinyWebInstance = require('./TinyWebInstance.cjs');
var Utils = require('./Utils.cjs');
/** @typedef {import('socket.io').Socket} Socket */
/**
* @typedef {number} HttpStatusCode
*/
/**
* @typedef {(socket: Socket) => string[]} IPExtractor
*/
/**
* @typedef {(socket: Socket) => boolean} DomainValidator
*/
/**
* Represents the structured data extracted from an HTTP Origin header.
*
* @typedef {Object} OriginData
* @property {string|null} raw - The raw Origin header value.
* @property {string} [protocol] - The protocol used (e.g., 'http' or 'https').
* @property {string} [hostname] - The hostname extracted from the origin.
* @property {string} [port] - The port number (explicit or default based on protocol).
* @property {string} [full] - The full reconstructed URL from the origin.
* @property {string} [error] - Any parsing error encountered while processing the origin.
*/
/**
* TinyIo is a wrapper for Socket.IO v4,
* designed to simplify WebSocket server management,
* client communication, and event handling.
*
* It offers streamlined integration and utility methods
* for real-time bidirectional communication.
*
* @class
*/
class TinyIo {
/** @type {HttpServer|HttpsServer|null} */
#server = null;
#domainTypeChecker = 'headerHost';
#forbiddenDomainMessage = 'Forbidden domain.';
/** @type {{ [key: string]: IPExtractor }} */
#ipExtractors = {};
/** @type {string} */
#activeExtractor = 'DEFAULT';
/**
* A list of domain validators using different request properties.
*
* Each property in this object represents a header or request property to check the domain against.
* Each associated array contains one or more callback functions that validate or extract the domain.
*
* @type {{
* [key: string]: DomainValidator
* }}
*/
#domainValidators = {};
/**
* Returns the current Http server instance.
*
* @returns {HttpServer | HttpsServer} The active Http server.
*/
#getServer() {
// @ts-ignore
if (!(this.#server instanceof http.Server) && !(this.#server instanceof https.Server))
throw new Error(
'this.web expects an instance of HttpServer or HttpsServer. Please execute the init().',
);
return this.#server;
}
/**
* Creates a new instance of the Socket.IO server wrapper.
*
* @param {HttpServer | HttpsServer | number | import('socket.io').ServerOptions} [server] - An instance of a Node.js HTTP/HTTPS server, a port number, or Socket.IO options.
* @param {import('socket.io').ServerOptions} [options] - Configuration options for the Socket.IO server instance.
*
* @throws {Error} If `server` is not a valid server, number, or object.
* @throws {Error} If `options` is not a non-null plain object.
*/
constructor(server, options) {
const isServerInstance = server instanceof http.Server || server instanceof https.Server;
const isNumber = typeof server === 'number';
const isPlainObject =
typeof server === 'object' && server !== null && !Array.isArray(server) && !isServerInstance;
/** @type {import('socket.io').ServerOptions} */
// @ts-ignore
const ops = isPlainObject && typeof options === 'undefined' ? server : options;
if (isPlainObject && typeof options === 'undefined') server = undefined;
if (typeof server !== 'undefined' && !isNumber && !isServerInstance)
throw new Error(
'Expected "server" to be an instance of http.Server, https.Server, number, or plain options object',
);
if (
typeof ops !== 'undefined' &&
(typeof ops !== 'object' || ops === null || Array.isArray(ops))
)
throw new Error('Expected "options" to be a non-null object');
this.#server = server instanceof http.Server || server instanceof https.Server ? server : null;
this.root = new socket_io.Server(
this.#server || server || ops,
this.#server || server ? ops : undefined,
);
if (!this.#server) this.#server = https.createServer();
this.root.on(
'connection',
/** @type {Socket} */
(socket) => {
const type = this.#domainTypeChecker;
let valid = false;
if (type === 'ALL') {
const validators = Object.values(this.#domainValidators);
for (const validator of validators) {
if (typeof validator !== 'function')
throw new Error(`Domain validator "${type}" is not registered.`);
valid = validator(socket);
if (valid) break;
}
} else {
const validator = this.#domainValidators[type];
if (typeof validator !== 'function')
throw new Error(`Domain validator "${type}" is not registered.`);
valid = validator(socket);
}
if (!valid) {
socket.emit('force_disconnect', { reason: this.#forbiddenDomainMessage });
socket.disconnect(true);
return;
}
},
);
}
/**
* Init instance.
*
* @param {TinyWebInstance} [web] - An instance of TinyWebInstance.
*
* @throws {Error} If `web` is not an instance of TinyWebInstance.
*/
init(web = new TinyWebInstance(this.#getServer())) {
/** @type {TinyWebInstance} */ this.web;
if (!(web instanceof TinyWebInstance))
throw new Error('init expects an instance of TinyWebInstance.');
this.web = web;
if (!this.web.hasDomain('localhost')) this.web.addDomain('localhost');
if (!this.web.hasDomain('127.0.0.1')) this.web.addDomain('127.0.0.1');
if (!this.web.hasDomain('::1')) this.web.addDomain('::1');
this.addDomainValidator('x-forwarded-host', (socket) =>
typeof socket.handshake.headers['x-forwarded-host'] === 'string'
? this.web.canDomain(socket.handshake.headers['x-forwarded-host'])
: false,
);
this.addDomainValidator('headerHost', (socket) =>
typeof socket.handshake.headers.host === 'string'
? this.web.canDomain(socket.handshake.headers.host)
: false,
);
this.addIpExtractor('ip', (socket) => Utils.extractIpList(socket.handshake.address));
this.addIpExtractor('x-forwarded-for', (socket) =>
Utils.extractIpList(socket.handshake.headers['x-forwarded-for']),
);
this.addIpExtractor('remoteAddress', (socket) =>
Utils.extractIpList(socket.request.socket.remoteAddress),
);
this.addIpExtractor('fastly-client-ip', (socket) =>
Utils.extractIpList(socket.handshake.headers['fastly-client-ip']),
);
}
/**
* Parses the Origin header from a Socket.IO connection handshake and extracts structured information.
*
* @param {Socket} socket - The connected Socket.IO socket instance.
* @returns {OriginData} An object containing detailed parts of the Origin header, or error info if parsing fails.
*/
getOrigin(socket) {
const headers = socket.handshake.headers;
/** @type {OriginData} */
const originInfo = {
raw: headers.origin || null,
};
try {
if (headers.origin) {
const url = new URL(headers.origin);
originInfo.protocol = url.protocol.replace(':', '');
originInfo.hostname = url.hostname;
originInfo.port = url.port || (url.protocol === 'https:' ? '443' : '80');
originInfo.full = url.href;
}
} catch (err) {
originInfo.error = 'Invalid Origin header';
}
return originInfo;
}
/**
* Returns the current TinyWeb instance.
*
* @returns {TinyWebInstance} The active Http server.
*/
getWeb() {
if (!(this.web instanceof TinyWebInstance))
throw new Error('this.web expects an instance of TinyWebInstance.');
return this.web;
}
/**
* Returns the current Http server instance.
*
* @returns {HttpServer | HttpsServer} The active Http server.
*/
getServer() {
return this.getWeb().getServer();
}
/**
* Returns the current Socket.IO server instance.
*
* @returns {import('socket.io').Server} The active Socket.IO server.
*/
getRoot() {
return this.root;
}
/**
* Registers a new IP extractor under a specific key.
*
* Each extractor must be a function that receives an Express `Request` and returns a string (IP) or null.
* The key `"DEFAULT"` is reserved and cannot be used directly.
*
* @param {string} key
* @param {IPExtractor} callback
* @throws {Error} If the key is invalid or already registered.
*/
addIpExtractor(key, callback) {
if (typeof key !== 'string') throw new Error('Extractor key must be a string.');
if (key === 'DEFAULT') throw new Error('"DEFAULT" is a reserved keyword.');
if (typeof callback !== 'function') throw new Error('Extractor must be a function.');
if (this.#ipExtractors[key]) throw new Error(`Extractor "${key}" already exists.`);
this.#ipExtractors[key] = callback;
}
/**
* Removes a registered IP extractor.
*
* Cannot remove the extractor currently in use unless it's set to "DEFAULT".
*
* @param {string} key
* @throws {Error} If the key is invalid or in use.
*/
removeIpExtractor(key) {
if (typeof key !== 'string') throw new Error('Extractor key must be a string.');
if (!(key in this.#ipExtractors)) throw new Error(`Extractor "${key}" not found.`);
if (this.#activeExtractor === key)
throw new Error(`Cannot remove extractor "${key}" because it is currently in use.`);
delete this.#ipExtractors[key];
}
/**
* Returns a shallow clone of the current extractors.
*
* @returns {{ [key: string]: IPExtractor }}
*/
getIpExtractors() {
return { ...this.#ipExtractors };
}
/**
* Sets the currently active extractor key.
*
* @param {string} key
* @throws {Error} If the key is not found.
*/
setActiveIpExtractor(key) {
if (key !== 'DEFAULT' && !(key in this.#ipExtractors))
throw new Error(`Extractor "${key}" not found.`);
this.#activeExtractor = key;
}
/**
* Returns the current extractor function based on the active key.
*
* @returns {IPExtractor}
*/
getActiveExtractor() {
if (this.#activeExtractor === 'DEFAULT') {
// Fallback behavior (standard Express logic)
return (socket) => {
const ips = [
...Utils.extractIpList(socket.handshake.headers['x-forwarded-for']),
...Utils.extractIpList(socket.handshake.address),
...Utils.extractIpList(socket.request.socket.remoteAddress),
];
const uniqueIps = [...new Set(ips)];
return uniqueIps;
};
}
return this.#ipExtractors[this.#activeExtractor];
}
/**
* Extracts the IP address from a request using the active extractor.
* @param {Socket} socket
* @returns {string[]}
*/
extractIp(socket) {
return this.getActiveExtractor()(socket);
}
/**
* Registers a new domain validator under a specific key.
*
* Each validator must be a function that receives an Express `Request` object and returns a boolean.
* The key `"ALL"` is reserved and cannot be used as a validator key.
*
* @param {string} key - The key name identifying the validator (e.g., 'host', 'x-forwarded-host').
* @param {DomainValidator} callback - The validation function to be added.
* @throws {Error} If the key is not a string.
* @throws {Error} If the key is "ALL".
* @throws {Error} If the callback is not a function.
* @throws {Error} If a validator with the same key already exists.
*/
addDomainValidator(key, callback) {
if (typeof key !== 'string') throw new Error('Validator key must be a string.');
if (key === 'ALL')
throw new Error('"ALL" is a reserved keyword and cannot be used as a validator key.');
if (typeof callback !== 'function') throw new Error('Validator callback must be a function.');
if (this.#domainValidators[key]) throw new Error(`Validator with key "${key}" already exists.`);
this.#domainValidators[key] = callback;
}
/**
* Removes a registered domain validator by its key.
*
* Cannot remove the validator currently being used by the domain type checker,
* unless the type checker is set to "ALL".
*
* @param {string} key - The key name of the validator to remove.
* @throws {Error} If the key is not a string.
* @throws {Error} If no validator is found under the given key.
* @throws {Error} If the validator is currently in use by the domain type checker.
*/
removeDomainValidator(key) {
if (typeof key !== 'string') throw new Error('Validator key must be a string.');
if (!(key in this.#domainValidators)) throw new Error(`Validator with key "${key}" not found.`);
if (this.#domainTypeChecker === key)
throw new Error(`Cannot remove validator "${key}" because it is currently in use.`);
delete this.#domainValidators[key];
}
/**
* Returns a shallow clone of the current domain validators map.
*
* The returned object maps each key to its corresponding validation function.
*
* @returns {{ [key: string]: DomainValidator }} A cloned object of the validators.
*/
getDomainValidators() {
return { ...this.#domainValidators };
}
/**
* Sets the current domain validation strategy by key name.
*
* The provided key must exist in the registered domain validators,
* or be the string `"ALL"` to enable all validators simultaneously (not recommended).
*
* @param {string} key - The key of the validator to use (e.g., 'hostname', 'x-forwarded-host'), or 'ALL'.
* @throws {Error} If the key is not a string.
* @throws {Error} If the key is not found among the validators and is not 'ALL'.
*/
setDomainTypeChecker(key) {
if (typeof key !== 'string') throw new Error('Domain type checker must be a string.');
if (key !== 'ALL' && !(key in this.#domainValidators))
throw new Error(`Validator key "${key}" is not registered.`);
this.#domainTypeChecker = key;
}
}
module.exports = TinyIo;