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.

519 lines (518 loc) 18 kB
/** @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(); } } export default TinyRateLimiter;