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
JavaScript
/** @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;