UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

572 lines (496 loc) 16.2 kB
'use strict'; /** @typedef {(groupId: string) => void} OnMemoryExceeded */ /** @typedef {(groupId: string) => void} OnGroupExpired */ /** * Class representing a flexible rate limiter per user or group. * * This rate limiter supports limiting per user or per group by mapping * userIds to a common groupId. All users within the same group share * rate limits. */ class TinyRateLimiter { /** @type {number|null} */ #maxMemory = null; /** @type {NodeJS.Timeout|null} */ #cleanupTimer = null; /** @type {number|null|undefined} */ #maxHits = null; /** @type {number|null|undefined} */ #interval = null; /** @type {number|null|undefined} */ #cleanupInterval = null; /** @type {number|null|undefined} */ #maxIdle = null; /** @type {Map<string, number[]>} */ groupData = new Map(); // groupId -> timestamps[] /** @type {Map<string, number>} */ lastSeen = new Map(); // groupId -> timestamp /** @type {Map<string, string>} */ userToGroup = new Map(); // userId -> groupId /** @type {Map<string, boolean>} */ groupFlags = new Map(); // groupId -> boolean /** * @type {Map<string, number>} * Stores TTL (in ms) for each groupId individually */ groupTTL = new Map(); /** * @type {null|OnMemoryExceeded} */ #onMemoryExceeded = null; /** * Set the callback to be triggered when a group exceeds its limit * @param {OnMemoryExceeded} callback */ setOnMemoryExceeded(callback) { if (typeof callback !== 'function') throw new Error('onMemoryExceeded must be a function'); this.#onMemoryExceeded = callback; } /** * Clear the onMemoryExceeded callback */ clearOnMemoryExceeded() { this.#onMemoryExceeded = null; } /** * @type {null|OnGroupExpired} */ #onGroupExpired = null; /** * Set the callback to be triggered when a group expires and is removed. * * This callback is called automatically during cleanup when a group * becomes inactive for longer than its TTL. * * @param {OnGroupExpired} callback - A function that receives the expired groupId. */ setOnGroupExpired(callback) { if (typeof callback !== 'function') throw new Error('onGroupExpired must be a function'); this.#onGroupExpired = callback; } /** * Clear the onGroupExpired callback */ clearOnGroupExpired() { this.#onGroupExpired = null; } /** * @param {Object} options * @param {number|null} [options.maxMemory] - Max memory allowed * @param {number} [options.maxHits] - Max interactions allowed * @param {number} [options.interval] - Time window in milliseconds * @param {number} [options.cleanupInterval] - Interval for automatic cleanup (ms) * @param {number} [options.maxIdle=300000] - Max idle time for a user before being cleaned (ms) */ constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) { /** @param {number|undefined} val */ const isPositiveInteger = (val) => typeof val === 'number' && Number.isFinite(val) && val >= 1 && Number.isInteger(val); const isMaxHitsValid = isPositiveInteger(maxHits); const isIntervalValid = isPositiveInteger(interval); const isCleanupValid = isPositiveInteger(cleanupInterval); const isMaxIdleValid = isPositiveInteger(maxIdle); if (!isMaxHitsValid && !isIntervalValid) throw new Error("RateLimiter requires at least one valid option: 'maxHits' or 'interval'."); if (maxHits !== undefined && !isMaxHitsValid) throw new Error("'maxHits' must be a positive integer if defined."); if (interval !== undefined && !isIntervalValid) throw new Error("'interval' must be a positive integer in milliseconds if defined."); if (cleanupInterval !== undefined && !isCleanupValid) throw new Error("'cleanupInterval' must be a positive integer in milliseconds if defined."); if (!isMaxIdleValid) throw new Error("'maxIdle' must be a positive integer in milliseconds."); if (typeof maxMemory === 'number' && Number.isFinite(maxMemory) && maxMemory > 0) { this.#maxMemory = Math.floor(maxMemory); } else if (maxMemory === null || maxMemory === undefined) { this.#maxMemory = null; } else { throw new Error('maxMemory must be a positive number or null'); } this.#maxHits = isMaxHitsValid ? maxHits : null; this.#interval = isIntervalValid ? interval : null; this.#cleanupInterval = isCleanupValid ? cleanupInterval : null; this.#maxIdle = maxIdle; // Start automatic cleanup only if cleanupInterval is valid if (this.#cleanupInterval !== null) this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval); } /** * Check if a given ID is a groupId (not a userId) * @param {string} id * @returns {boolean} */ isGroupId(id) { const result = this.groupFlags.get(id); return typeof result === 'boolean' ? result : false; } /** * Get all user IDs that belong to a given group. * @param {string} groupId * @returns {string[]} */ getUsersInGroup(groupId) { const users = []; for (const [userId, assignedGroup] of this.userToGroup.entries()) { if (assignedGroup === groupId) { users.push(userId); } } return users; } /** * Set TTL (in milliseconds) for a specific group * @param {string} groupId * @param {number} ttl */ setGroupTTL(groupId, ttl) { if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0) throw new Error('TTL must be a positive number in milliseconds'); this.groupTTL.set(groupId, ttl); } /** * Get TTL (in ms) for a specific group. * @param {string} groupId * @returns {number|null} */ getGroupTTL(groupId) { return this.groupTTL.get(groupId) ?? null; } /** * Delete the TTL setting for a specific group * @param {string} groupId */ deleteGroupTTL(groupId) { this.groupTTL.delete(groupId); } /** * Assign a userId to a groupId, with merge if user has existing data. * @param {string} userId * @param {string} groupId * @throws {Error} If userId is already assigned to a different group */ assignToGroup(userId, groupId) { const existingGroup = this.userToGroup.get(userId); if (existingGroup && existingGroup !== groupId) throw new Error(`User ${userId} is already assigned to group ${existingGroup}`); // If the user is already in the group, nothing needs to be done if (existingGroup === groupId) return; const userData = this.groupData.get(userId); // Associates the user to the group if (this.isGroupId(userId)) { for (const [uid, gId] of this.userToGroup.entries()) if (gId === userId) this.userToGroup.set(uid, groupId); this.userToGroup.delete(userId); } else this.userToGroup.set(userId, groupId); // If the user has no data, nothing needs to be done if (!userData) return; const groupData = this.groupData.get(groupId); if (groupData) { for (const item of userData) groupData.push(item); } else { const newData = []; for (const item of userData) newData.push(item); this.groupData.set(groupId, newData); } this.lastSeen.set(groupId, Date.now()); // Removes individual data as they are now in the group this.groupFlags.delete(userId); this.groupData.delete(userId); this.lastSeen.delete(userId); this.groupTTL.delete(userId); this.groupFlags.set(groupId, true); } /** * Get the groupId for a given userId * @param {string} userId * @returns {string} */ getGroupId(userId) { return this.userToGroup.get(userId) || userId; // fallback: use userId as own group } /** * Register a hit for a specific user * @param {string} userId */ hit(userId) { const groupId = this.getGroupId(userId); const now = Date.now(); if (!this.groupData.has(groupId)) { this.groupData.set(groupId, []); this.groupFlags.set(groupId, false); } const history = this.groupData.get(groupId); if (!history) throw new Error(`No data found for groupId: ${groupId}`); history.push(now); this.lastSeen.set(groupId, now); // Clean up old entries if (this.#interval !== null) { const interval = this.getInterval(); const cutoff = now - interval; while (history.length && history[0] < cutoff) { history.shift(); } } // Optional: keep only the last N entries for memory optimization if (this.#maxMemory !== null && typeof this.#maxMemory === 'number') { if (history.length > this.#maxMemory) { history.splice(0, history.length - this.#maxMemory); if (typeof this.#onMemoryExceeded === 'function') this.#onMemoryExceeded(groupId); } } } /** * Check if the user (via their group) is currently rate limited * @param {string} userId * @returns {boolean} */ isRateLimited(userId) { const groupId = this.getGroupId(userId); if (!this.groupData.has(groupId)) return false; const history = this.groupData.get(groupId); if (!history) throw new Error(`No data found for groupId: ${groupId}`); if (this.#interval !== null) { const now = Date.now(); const interval = this.getInterval(); const cutoff = now - interval; let count = 0; for (let i = history.length - 1; i >= 0; i--) { if (history[i] > cutoff) count++; else break; } if (this.#maxHits !== null) return count > this.getMaxHits(); return count > 0; } if (this.#maxHits !== null) { return history.length > this.getMaxHits(); } return false; } /** * Manually reset group data * @param {string} groupId */ resetGroup(groupId) { this.groupFlags.delete(groupId); this.groupData.delete(groupId); this.lastSeen.delete(groupId); this.groupTTL.delete(groupId); } /** * Manually reset user data. * * @deprecated Use `resetUserGroup(userId)` instead. This method will be removed in future versions. * @param {string} userId * @returns {void} */ reset(userId) { if (process?.env?.NODE_ENV !== 'production') console.warn(`[TinyRateLimiter] 'reset()' is deprecated. Use 'resetUserGroup()' instead.`); return this.resetUserGroup(userId); } /** * Manually reset a user mapping * @param {string} userId */ resetUserGroup(userId) { this.userToGroup.delete(userId); } /** * Set custom timestamps to a group * @param {string} groupId * @param {number[]} timestamps */ setData(groupId, timestamps) { if (!Array.isArray(timestamps)) throw new Error('timestamps must be an array of numbers.'); for (const t of timestamps) { if (typeof t !== 'number' || !Number.isFinite(t)) { throw new Error('All timestamps must be finite numbers.'); } } if (!this.groupData.has(groupId)) this.groupFlags.set(groupId, false); this.groupData.set(groupId, timestamps); this.lastSeen.set(groupId, Date.now()); } /** * Check if a group has data * @param {string} groupId * @returns {boolean} */ hasData(groupId) { return this.groupData.has(groupId); } /** * Get timestamps from a group * @param {string} groupId * @returns {number[]} */ getData(groupId) { return this.groupData.get(groupId) || []; } /** * Get the maximum idle time (in milliseconds) before a group is considered expired. * @returns {number} */ getMaxIdle() { if (typeof this.#maxIdle !== 'number' || !Number.isFinite(this.#maxIdle) || this.#maxIdle < 0) { throw new Error("'maxIdle' must be a non-negative finite number."); } return this.#maxIdle; } /** * Set the maximum idle time (in milliseconds) before a group is considered expired. * @param {number} ms */ setMaxIdle(ms) { if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) { throw new Error("'maxIdle' must be a non-negative finite number."); } this.#maxIdle = ms; } /** * Cleanup old/inactive groups with individual TTLs * @private */ _cleanup() { const now = Date.now(); for (const [groupId, last] of this.lastSeen.entries()) { const ttl = this.getGroupTTL(groupId) ?? this.getMaxIdle(); if (now - last > ttl) { this.groupFlags.delete(groupId); this.groupData.delete(groupId); this.lastSeen.delete(groupId); this.groupTTL.delete(groupId); // Notify subclass or external binding if (typeof this.#onGroupExpired === 'function') { this.#onGroupExpired(groupId); } } } } /** * Get list of active group IDs * @returns {string[]} */ getActiveGroups() { return Array.from(this.groupData.keys()); } /** * Get a shallow copy of all user-to-group mappings as a plain object * @returns {Record<string, string>} */ getAllUserMappings() { return Object.fromEntries(this.userToGroup); } /** * Get the interval window in milliseconds. * @returns {number} */ getInterval() { if (typeof this.#interval !== 'number' || !Number.isFinite(this.#interval)) { throw new Error("'interval' is not a valid finite number."); } return this.#interval; } /** * Get the maximum number of allowed hits. * @returns {number} */ getMaxHits() { if (typeof this.#maxHits !== 'number' || !Number.isFinite(this.#maxHits)) { throw new Error("'maxHits' is not a valid finite number."); } return this.#maxHits; } /** * Get the total number of hits recorded for a group. * @param {string} groupId * @returns {number} */ getTotalHits(groupId) { const history = this.groupData.get(groupId); return Array.isArray(history) ? history.length : 0; } /** * Get the timestamp of the last hit for a group. * @param {string} groupId * @returns {number|null} */ getLastHit(groupId) { const history = this.groupData.get(groupId); return history?.length ? history[history.length - 1] : null; } /** * Get milliseconds since the last hit for a group. * @param {string} groupId * @returns {number|null} */ getTimeSinceLastHit(groupId) { const last = this.getLastHit(groupId); return last !== null ? Date.now() - last : null; } /** * Internal utility to compute average spacing * @private * @param {number[]|undefined} history * @returns {number|null} */ _calculateAverageSpacing(history) { if (!Array.isArray(history) || history.length < 2) return null; let total = 0; for (let i = 1; i < history.length; i++) { total += history[i] - history[i - 1]; } return total / (history.length - 1); } /** * Get average time between hits for a group (ms). * @param {string} groupId * @returns {number|null} */ getAverageHitSpacing(groupId) { return this._calculateAverageSpacing(this.groupData.get(groupId)); } /** * Get metrics about a group's activity. * @param {string} groupId * @returns {{ * totalHits: number, * lastHit: number|null, * timeSinceLastHit: number|null, * averageHitSpacing: number|null * }} */ getMetrics(groupId) { const history = this.groupData.get(groupId); if (!Array.isArray(history) || history.length === 0) { return { totalHits: 0, lastHit: null, timeSinceLastHit: null, averageHitSpacing: null, }; } const totalHits = history.length; const lastHit = history[totalHits - 1]; const timeSinceLastHit = Date.now() - lastHit; const averageHitSpacing = this._calculateAverageSpacing(history); return { totalHits, lastHit, timeSinceLastHit, averageHitSpacing, }; } /** * Destroy the rate limiter, stopping cleanup and clearing data */ destroy() { if (this.#cleanupTimer) clearInterval(this.#cleanupTimer); this._cleanup(); this.groupData.clear(); this.lastSeen.clear(); this.userToGroup.clear(); this.groupTTL.clear(); this.groupFlags.clear(); } } module.exports = TinyRateLimiter;