tiny-essentials 1.9.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/TinyEssentials.js +450 -76
- package/dist/TinyEssentials.min.js +1 -1
- package/dist/TinyRateLimiter.js +450 -76
- package/dist/TinyRateLimiter.min.js +1 -1
- package/dist/v1/libs/TinyRateLimiter.cjs +450 -76
- package/dist/v1/libs/TinyRateLimiter.d.mts +186 -31
- package/dist/v1/libs/TinyRateLimiter.mjs +420 -75
- package/docs/libs/TinyRateLimiter.md +167 -15
- package/package.json +1 -1
package/dist/TinyEssentials.js
CHANGED
|
@@ -3738,24 +3738,109 @@ class TinyPromiseQueue {
|
|
|
3738
3738
|
/* harmony default export */ const libs_TinyPromiseQueue = (TinyPromiseQueue);
|
|
3739
3739
|
|
|
3740
3740
|
;// ./src/v1/libs/TinyRateLimiter.mjs
|
|
3741
|
+
/** @typedef {(groupId: string) => void} OnMemoryExceeded */
|
|
3742
|
+
|
|
3743
|
+
/** @typedef {(groupId: string) => void} OnGroupExpired */
|
|
3744
|
+
|
|
3741
3745
|
/**
|
|
3742
|
-
* Class representing a flexible rate limiter per user.
|
|
3743
|
-
*
|
|
3744
|
-
* This rate limiter can be configured by maximum number of hits,
|
|
3745
|
-
* time interval, or a combination of both. It supports automatic
|
|
3746
|
-
* cleanup of inactive users to optimize memory usage.
|
|
3746
|
+
* Class representing a flexible rate limiter per user or group.
|
|
3747
3747
|
*
|
|
3748
|
-
*
|
|
3748
|
+
* This rate limiter supports limiting per user or per group by mapping
|
|
3749
|
+
* userIds to a common groupId. All users within the same group share
|
|
3750
|
+
* rate limits.
|
|
3749
3751
|
*/
|
|
3750
3752
|
class TinyRateLimiter {
|
|
3753
|
+
/** @type {number|null} */
|
|
3754
|
+
#maxMemory = null;
|
|
3755
|
+
|
|
3756
|
+
/** @type {NodeJS.Timeout|null} */
|
|
3757
|
+
#cleanupTimer = null;
|
|
3758
|
+
|
|
3759
|
+
/** @type {number|null|undefined} */
|
|
3760
|
+
#maxHits = null;
|
|
3761
|
+
|
|
3762
|
+
/** @type {number|null|undefined} */
|
|
3763
|
+
#interval = null;
|
|
3764
|
+
|
|
3765
|
+
/** @type {number|null|undefined} */
|
|
3766
|
+
#cleanupInterval = null;
|
|
3767
|
+
|
|
3768
|
+
/** @type {number|null|undefined} */
|
|
3769
|
+
#maxIdle = null;
|
|
3770
|
+
|
|
3771
|
+
/** @type {Map<string, number[]>} */
|
|
3772
|
+
groupData = new Map(); // groupId -> timestamps[]
|
|
3773
|
+
|
|
3774
|
+
/** @type {Map<string, number>} */
|
|
3775
|
+
lastSeen = new Map(); // groupId -> timestamp
|
|
3776
|
+
|
|
3777
|
+
/** @type {Map<string, string>} */
|
|
3778
|
+
userToGroup = new Map(); // userId -> groupId
|
|
3779
|
+
|
|
3780
|
+
/** @type {Map<string, boolean>} */
|
|
3781
|
+
groupFlags = new Map(); // groupId -> boolean
|
|
3782
|
+
|
|
3783
|
+
/**
|
|
3784
|
+
* @type {Map<string, number>}
|
|
3785
|
+
* Stores TTL (in ms) for each groupId individually
|
|
3786
|
+
*/
|
|
3787
|
+
groupTTL = new Map();
|
|
3788
|
+
|
|
3789
|
+
/**
|
|
3790
|
+
* @type {null|OnMemoryExceeded}
|
|
3791
|
+
*/
|
|
3792
|
+
#onMemoryExceeded = null;
|
|
3793
|
+
|
|
3794
|
+
/**
|
|
3795
|
+
* Set the callback to be triggered when a group exceeds its limit
|
|
3796
|
+
* @param {OnMemoryExceeded} callback
|
|
3797
|
+
*/
|
|
3798
|
+
setOnMemoryExceeded(callback) {
|
|
3799
|
+
if (typeof callback !== 'function') throw new Error('onMemoryExceeded must be a function');
|
|
3800
|
+
this.#onMemoryExceeded = callback;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/**
|
|
3804
|
+
* Clear the onMemoryExceeded callback
|
|
3805
|
+
*/
|
|
3806
|
+
clearOnMemoryExceeded() {
|
|
3807
|
+
this.#onMemoryExceeded = null;
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
/**
|
|
3811
|
+
* @type {null|OnGroupExpired}
|
|
3812
|
+
*/
|
|
3813
|
+
#onGroupExpired = null;
|
|
3814
|
+
|
|
3815
|
+
/**
|
|
3816
|
+
* Set the callback to be triggered when a group expires and is removed.
|
|
3817
|
+
*
|
|
3818
|
+
* This callback is called automatically during cleanup when a group
|
|
3819
|
+
* becomes inactive for longer than its TTL.
|
|
3820
|
+
*
|
|
3821
|
+
* @param {OnGroupExpired} callback - A function that receives the expired groupId.
|
|
3822
|
+
*/
|
|
3823
|
+
setOnGroupExpired(callback) {
|
|
3824
|
+
if (typeof callback !== 'function') throw new Error('onGroupExpired must be a function');
|
|
3825
|
+
this.#onGroupExpired = callback;
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* Clear the onGroupExpired callback
|
|
3830
|
+
*/
|
|
3831
|
+
clearOnGroupExpired() {
|
|
3832
|
+
this.#onGroupExpired = null;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3751
3835
|
/**
|
|
3752
3836
|
* @param {Object} options
|
|
3837
|
+
* @param {number|null} [options.maxMemory] - Max memory allowed
|
|
3753
3838
|
* @param {number} [options.maxHits] - Max interactions allowed
|
|
3754
3839
|
* @param {number} [options.interval] - Time window in milliseconds
|
|
3755
3840
|
* @param {number} [options.cleanupInterval] - Interval for automatic cleanup (ms)
|
|
3756
3841
|
* @param {number} [options.maxIdle=300000] - Max idle time for a user before being cleaned (ms)
|
|
3757
3842
|
*/
|
|
3758
|
-
constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000 }) {
|
|
3843
|
+
constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) {
|
|
3759
3844
|
/** @param {number|undefined} val */
|
|
3760
3845
|
const isPositiveInteger = (val) =>
|
|
3761
3846
|
typeof val === 'number' && Number.isFinite(val) && val >= 1 && Number.isInteger(val);
|
|
@@ -3775,45 +3860,129 @@ class TinyRateLimiter {
|
|
|
3775
3860
|
throw new Error("'cleanupInterval' must be a positive integer in milliseconds if defined.");
|
|
3776
3861
|
if (!isMaxIdleValid) throw new Error("'maxIdle' must be a positive integer in milliseconds.");
|
|
3777
3862
|
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3863
|
+
if (typeof maxMemory === 'number' && Number.isFinite(maxMemory) && maxMemory > 0) {
|
|
3864
|
+
this.#maxMemory = Math.floor(maxMemory);
|
|
3865
|
+
} else if (maxMemory === null || maxMemory === undefined) {
|
|
3866
|
+
this.#maxMemory = null;
|
|
3867
|
+
} else {
|
|
3868
|
+
throw new Error('maxMemory must be a positive number or null');
|
|
3869
|
+
}
|
|
3785
3870
|
|
|
3786
|
-
|
|
3787
|
-
this
|
|
3871
|
+
this.#maxHits = isMaxHitsValid ? maxHits : null;
|
|
3872
|
+
this.#interval = isIntervalValid ? interval : null;
|
|
3873
|
+
this.#cleanupInterval = isCleanupValid ? cleanupInterval : null;
|
|
3874
|
+
this.#maxIdle = maxIdle;
|
|
3788
3875
|
|
|
3789
3876
|
// Start automatic cleanup only if cleanupInterval is valid
|
|
3790
|
-
if (this
|
|
3791
|
-
this
|
|
3877
|
+
if (this.#cleanupInterval !== null)
|
|
3878
|
+
this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
|
|
3792
3879
|
}
|
|
3793
3880
|
|
|
3794
3881
|
/**
|
|
3795
|
-
*
|
|
3796
|
-
*
|
|
3797
|
-
* @returns {
|
|
3798
|
-
* @throws {Error} If interval is not a valid finite number.
|
|
3882
|
+
* Check if a given ID is a groupId (not a userId)
|
|
3883
|
+
* @param {string} id
|
|
3884
|
+
* @returns {boolean}
|
|
3799
3885
|
*/
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
return this.interval;
|
|
3886
|
+
isGroupId(id) {
|
|
3887
|
+
const result = this.groupFlags.get(id);
|
|
3888
|
+
return typeof result === 'boolean' ? result : false;
|
|
3804
3889
|
}
|
|
3805
3890
|
|
|
3806
3891
|
/**
|
|
3807
|
-
* Get
|
|
3808
|
-
*
|
|
3809
|
-
* @returns {
|
|
3810
|
-
* @throws {Error} If maxHits is not a valid finite number.
|
|
3892
|
+
* Get all user IDs that belong to a given group.
|
|
3893
|
+
* @param {string} groupId
|
|
3894
|
+
* @returns {string[]}
|
|
3811
3895
|
*/
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3896
|
+
getUsersInGroup(groupId) {
|
|
3897
|
+
const users = [];
|
|
3898
|
+
for (const [userId, assignedGroup] of this.userToGroup.entries()) {
|
|
3899
|
+
if (assignedGroup === groupId) {
|
|
3900
|
+
users.push(userId);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
return users;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
/**
|
|
3908
|
+
* Set TTL (in milliseconds) for a specific group
|
|
3909
|
+
* @param {string} groupId
|
|
3910
|
+
* @param {number} ttl
|
|
3911
|
+
*/
|
|
3912
|
+
setGroupTTL(groupId, ttl) {
|
|
3913
|
+
if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0)
|
|
3914
|
+
throw new Error('TTL must be a positive number in milliseconds');
|
|
3915
|
+
this.groupTTL.set(groupId, ttl);
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
/**
|
|
3919
|
+
* Get TTL (in ms) for a specific group.
|
|
3920
|
+
* @param {string} groupId
|
|
3921
|
+
* @returns {number|null}
|
|
3922
|
+
*/
|
|
3923
|
+
getGroupTTL(groupId) {
|
|
3924
|
+
return this.groupTTL.get(groupId) ?? null;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* Delete the TTL setting for a specific group
|
|
3929
|
+
* @param {string} groupId
|
|
3930
|
+
*/
|
|
3931
|
+
deleteGroupTTL(groupId) {
|
|
3932
|
+
this.groupTTL.delete(groupId);
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
/**
|
|
3936
|
+
* Assign a userId to a groupId, with merge if user has existing data.
|
|
3937
|
+
* @param {string} userId
|
|
3938
|
+
* @param {string} groupId
|
|
3939
|
+
* @throws {Error} If userId is already assigned to a different group
|
|
3940
|
+
*/
|
|
3941
|
+
assignToGroup(userId, groupId) {
|
|
3942
|
+
const existingGroup = this.userToGroup.get(userId);
|
|
3943
|
+
if (existingGroup && existingGroup !== groupId)
|
|
3944
|
+
throw new Error(`User ${userId} is already assigned to group ${existingGroup}`);
|
|
3945
|
+
|
|
3946
|
+
// If the user is already in the group, nothing needs to be done
|
|
3947
|
+
if (existingGroup === groupId) return;
|
|
3948
|
+
const userData = this.groupData.get(userId);
|
|
3949
|
+
|
|
3950
|
+
// Associates the user to the group
|
|
3951
|
+
if (this.isGroupId(userId)) {
|
|
3952
|
+
for (const [uid, gId] of this.userToGroup.entries())
|
|
3953
|
+
if (gId === userId) this.userToGroup.set(uid, groupId);
|
|
3954
|
+
this.userToGroup.delete(userId);
|
|
3955
|
+
} else this.userToGroup.set(userId, groupId);
|
|
3956
|
+
|
|
3957
|
+
// If the user has no data, nothing needs to be done
|
|
3958
|
+
if (!userData) return;
|
|
3959
|
+
|
|
3960
|
+
const groupData = this.groupData.get(groupId);
|
|
3961
|
+
if (groupData) {
|
|
3962
|
+
for (const item of userData) groupData.push(item);
|
|
3963
|
+
} else {
|
|
3964
|
+
const newData = [];
|
|
3965
|
+
for (const item of userData) newData.push(item);
|
|
3966
|
+
this.groupData.set(groupId, newData);
|
|
3815
3967
|
}
|
|
3816
|
-
|
|
3968
|
+
|
|
3969
|
+
this.lastSeen.set(groupId, Date.now());
|
|
3970
|
+
|
|
3971
|
+
// Removes individual data as they are now in the group
|
|
3972
|
+
this.groupFlags.delete(userId);
|
|
3973
|
+
this.groupData.delete(userId);
|
|
3974
|
+
this.lastSeen.delete(userId);
|
|
3975
|
+
this.groupTTL.delete(userId);
|
|
3976
|
+
this.groupFlags.set(groupId, true);
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
/**
|
|
3980
|
+
* Get the groupId for a given userId
|
|
3981
|
+
* @param {string} userId
|
|
3982
|
+
* @returns {string}
|
|
3983
|
+
*/
|
|
3984
|
+
getGroupId(userId) {
|
|
3985
|
+
return this.userToGroup.get(userId) || userId; // fallback: use userId as own group
|
|
3817
3986
|
}
|
|
3818
3987
|
|
|
3819
3988
|
/**
|
|
@@ -3821,20 +3990,22 @@ class TinyRateLimiter {
|
|
|
3821
3990
|
* @param {string} userId
|
|
3822
3991
|
*/
|
|
3823
3992
|
hit(userId) {
|
|
3993
|
+
const groupId = this.getGroupId(userId);
|
|
3824
3994
|
const now = Date.now();
|
|
3825
3995
|
|
|
3826
|
-
if (!this.
|
|
3827
|
-
this.
|
|
3996
|
+
if (!this.groupData.has(groupId)) {
|
|
3997
|
+
this.groupData.set(groupId, []);
|
|
3998
|
+
this.groupFlags.set(groupId, false);
|
|
3828
3999
|
}
|
|
3829
4000
|
|
|
3830
|
-
const history = this.
|
|
3831
|
-
if (!history) throw new Error(`No data found for
|
|
4001
|
+
const history = this.groupData.get(groupId);
|
|
4002
|
+
if (!history) throw new Error(`No data found for groupId: ${groupId}`);
|
|
3832
4003
|
|
|
3833
4004
|
history.push(now);
|
|
3834
|
-
this.lastSeen.set(
|
|
4005
|
+
this.lastSeen.set(groupId, now);
|
|
3835
4006
|
|
|
3836
4007
|
// Clean up old entries
|
|
3837
|
-
if (this
|
|
4008
|
+
if (this.#interval !== null) {
|
|
3838
4009
|
const interval = this.getInterval();
|
|
3839
4010
|
const cutoff = now - interval;
|
|
3840
4011
|
while (history.length && history[0] < cutoff) {
|
|
@@ -3843,92 +4014,295 @@ class TinyRateLimiter {
|
|
|
3843
4014
|
}
|
|
3844
4015
|
|
|
3845
4016
|
// Optional: keep only the last N entries for memory optimization
|
|
3846
|
-
if (this
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
4017
|
+
if (this.#maxMemory !== null && typeof this.#maxMemory === 'number') {
|
|
4018
|
+
if (history.length > this.#maxMemory) {
|
|
4019
|
+
history.splice(0, history.length - this.#maxMemory);
|
|
4020
|
+
if (typeof this.#onMemoryExceeded === 'function') this.#onMemoryExceeded(groupId);
|
|
3850
4021
|
}
|
|
3851
4022
|
}
|
|
3852
4023
|
}
|
|
3853
4024
|
|
|
3854
4025
|
/**
|
|
3855
|
-
* Check if the user is currently rate limited
|
|
4026
|
+
* Check if the user (via their group) is currently rate limited
|
|
3856
4027
|
* @param {string} userId
|
|
3857
4028
|
* @returns {boolean}
|
|
3858
4029
|
*/
|
|
3859
4030
|
isRateLimited(userId) {
|
|
3860
|
-
const
|
|
3861
|
-
|
|
3862
|
-
if (!this.userData.has(userId)) return false;
|
|
4031
|
+
const groupId = this.getGroupId(userId);
|
|
4032
|
+
if (!this.groupData.has(groupId)) return false;
|
|
3863
4033
|
|
|
3864
|
-
const history = this.
|
|
3865
|
-
if (!history) throw new Error(`No data found for
|
|
4034
|
+
const history = this.groupData.get(groupId);
|
|
4035
|
+
if (!history) throw new Error(`No data found for groupId: ${groupId}`);
|
|
3866
4036
|
|
|
3867
|
-
if (this
|
|
4037
|
+
if (this.#interval !== null) {
|
|
4038
|
+
const now = Date.now();
|
|
3868
4039
|
const interval = this.getInterval();
|
|
3869
|
-
const
|
|
3870
|
-
|
|
3871
|
-
|
|
4040
|
+
const cutoff = now - interval;
|
|
4041
|
+
let count = 0;
|
|
4042
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
4043
|
+
if (history[i] > cutoff) count++;
|
|
4044
|
+
else break;
|
|
3872
4045
|
}
|
|
3873
|
-
return
|
|
4046
|
+
if (this.#maxHits !== null) return count > this.getMaxHits();
|
|
4047
|
+
return count > 0;
|
|
3874
4048
|
}
|
|
3875
4049
|
|
|
3876
|
-
if (this
|
|
3877
|
-
return history.length
|
|
4050
|
+
if (this.#maxHits !== null) {
|
|
4051
|
+
return history.length > this.getMaxHits();
|
|
3878
4052
|
}
|
|
3879
4053
|
|
|
3880
4054
|
return false;
|
|
3881
4055
|
}
|
|
3882
4056
|
|
|
3883
4057
|
/**
|
|
3884
|
-
* Manually reset
|
|
4058
|
+
* Manually reset group data
|
|
4059
|
+
* @param {string} groupId
|
|
4060
|
+
*/
|
|
4061
|
+
resetGroup(groupId) {
|
|
4062
|
+
this.groupFlags.delete(groupId);
|
|
4063
|
+
this.groupData.delete(groupId);
|
|
4064
|
+
this.lastSeen.delete(groupId);
|
|
4065
|
+
this.groupTTL.delete(groupId);
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
/**
|
|
4069
|
+
* Manually reset user data.
|
|
4070
|
+
*
|
|
4071
|
+
* @deprecated Use `resetUserGroup(userId)` instead. This method will be removed in future versions.
|
|
3885
4072
|
* @param {string} userId
|
|
4073
|
+
* @returns {void}
|
|
3886
4074
|
*/
|
|
3887
4075
|
reset(userId) {
|
|
3888
|
-
|
|
3889
|
-
|
|
4076
|
+
if (false)
|
|
4077
|
+
{}
|
|
4078
|
+
return this.resetUserGroup(userId);
|
|
3890
4079
|
}
|
|
3891
4080
|
|
|
3892
4081
|
/**
|
|
3893
|
-
*
|
|
4082
|
+
* Manually reset a user mapping
|
|
3894
4083
|
* @param {string} userId
|
|
4084
|
+
*/
|
|
4085
|
+
resetUserGroup(userId) {
|
|
4086
|
+
this.userToGroup.delete(userId);
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
/**
|
|
4090
|
+
* Set custom timestamps to a group
|
|
4091
|
+
* @param {string} groupId
|
|
3895
4092
|
* @param {number[]} timestamps
|
|
3896
4093
|
*/
|
|
3897
|
-
setData(
|
|
3898
|
-
|
|
3899
|
-
|
|
4094
|
+
setData(groupId, timestamps) {
|
|
4095
|
+
if (!Array.isArray(timestamps)) throw new Error('timestamps must be an array of numbers.');
|
|
4096
|
+
for (const t of timestamps) {
|
|
4097
|
+
if (typeof t !== 'number' || !Number.isFinite(t)) {
|
|
4098
|
+
throw new Error('All timestamps must be finite numbers.');
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
if (!this.groupData.has(groupId)) this.groupFlags.set(groupId, false);
|
|
4102
|
+
this.groupData.set(groupId, timestamps);
|
|
4103
|
+
this.lastSeen.set(groupId, Date.now());
|
|
3900
4104
|
}
|
|
3901
4105
|
|
|
3902
4106
|
/**
|
|
3903
|
-
*
|
|
3904
|
-
* @param {string}
|
|
4107
|
+
* Check if a group has data
|
|
4108
|
+
* @param {string} groupId
|
|
4109
|
+
* @returns {boolean}
|
|
4110
|
+
*/
|
|
4111
|
+
hasData(groupId) {
|
|
4112
|
+
return this.groupData.has(groupId);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
/**
|
|
4116
|
+
* Get timestamps from a group
|
|
4117
|
+
* @param {string} groupId
|
|
3905
4118
|
* @returns {number[]}
|
|
3906
4119
|
*/
|
|
3907
|
-
getData(
|
|
3908
|
-
return this.
|
|
4120
|
+
getData(groupId) {
|
|
4121
|
+
return this.groupData.get(groupId) || [];
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
/**
|
|
4125
|
+
* Get the maximum idle time (in milliseconds) before a group is considered expired.
|
|
4126
|
+
* @returns {number}
|
|
4127
|
+
*/
|
|
4128
|
+
getMaxIdle() {
|
|
4129
|
+
if (typeof this.#maxIdle !== 'number' || !Number.isFinite(this.#maxIdle) || this.#maxIdle < 0) {
|
|
4130
|
+
throw new Error("'maxIdle' must be a non-negative finite number.");
|
|
4131
|
+
}
|
|
4132
|
+
return this.#maxIdle;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
/**
|
|
4136
|
+
* Set the maximum idle time (in milliseconds) before a group is considered expired.
|
|
4137
|
+
* @param {number} ms
|
|
4138
|
+
*/
|
|
4139
|
+
setMaxIdle(ms) {
|
|
4140
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
|
4141
|
+
throw new Error("'maxIdle' must be a non-negative finite number.");
|
|
4142
|
+
}
|
|
4143
|
+
this.#maxIdle = ms;
|
|
3909
4144
|
}
|
|
3910
4145
|
|
|
3911
4146
|
/**
|
|
3912
|
-
* Cleanup old/inactive
|
|
4147
|
+
* Cleanup old/inactive groups with individual TTLs
|
|
3913
4148
|
* @private
|
|
3914
4149
|
*/
|
|
3915
4150
|
_cleanup() {
|
|
3916
4151
|
const now = Date.now();
|
|
3917
|
-
for (const [
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
this.
|
|
4152
|
+
for (const [groupId, last] of this.lastSeen.entries()) {
|
|
4153
|
+
const ttl = this.getGroupTTL(groupId) ?? this.getMaxIdle();
|
|
4154
|
+
if (now - last > ttl) {
|
|
4155
|
+
this.groupFlags.delete(groupId);
|
|
4156
|
+
this.groupData.delete(groupId);
|
|
4157
|
+
this.lastSeen.delete(groupId);
|
|
4158
|
+
this.groupTTL.delete(groupId);
|
|
4159
|
+
|
|
4160
|
+
// Notify subclass or external binding
|
|
4161
|
+
if (typeof this.#onGroupExpired === 'function') {
|
|
4162
|
+
this.#onGroupExpired(groupId);
|
|
4163
|
+
}
|
|
3921
4164
|
}
|
|
3922
4165
|
}
|
|
3923
4166
|
}
|
|
3924
4167
|
|
|
3925
4168
|
/**
|
|
3926
|
-
*
|
|
4169
|
+
* Get list of active group IDs
|
|
4170
|
+
* @returns {string[]}
|
|
4171
|
+
*/
|
|
4172
|
+
getActiveGroups() {
|
|
4173
|
+
return Array.from(this.groupData.keys());
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
/**
|
|
4177
|
+
* Get a shallow copy of all user-to-group mappings as a plain object
|
|
4178
|
+
* @returns {Record<string, string>}
|
|
4179
|
+
*/
|
|
4180
|
+
getAllUserMappings() {
|
|
4181
|
+
return Object.fromEntries(this.userToGroup);
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
/**
|
|
4185
|
+
* Get the interval window in milliseconds.
|
|
4186
|
+
* @returns {number}
|
|
4187
|
+
*/
|
|
4188
|
+
getInterval() {
|
|
4189
|
+
if (typeof this.#interval !== 'number' || !Number.isFinite(this.#interval)) {
|
|
4190
|
+
throw new Error("'interval' is not a valid finite number.");
|
|
4191
|
+
}
|
|
4192
|
+
return this.#interval;
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
/**
|
|
4196
|
+
* Get the maximum number of allowed hits.
|
|
4197
|
+
* @returns {number}
|
|
4198
|
+
*/
|
|
4199
|
+
getMaxHits() {
|
|
4200
|
+
if (typeof this.#maxHits !== 'number' || !Number.isFinite(this.#maxHits)) {
|
|
4201
|
+
throw new Error("'maxHits' is not a valid finite number.");
|
|
4202
|
+
}
|
|
4203
|
+
return this.#maxHits;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
/**
|
|
4207
|
+
* Get the total number of hits recorded for a group.
|
|
4208
|
+
* @param {string} groupId
|
|
4209
|
+
* @returns {number}
|
|
4210
|
+
*/
|
|
4211
|
+
getTotalHits(groupId) {
|
|
4212
|
+
const history = this.groupData.get(groupId);
|
|
4213
|
+
return Array.isArray(history) ? history.length : 0;
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
/**
|
|
4217
|
+
* Get the timestamp of the last hit for a group.
|
|
4218
|
+
* @param {string} groupId
|
|
4219
|
+
* @returns {number|null}
|
|
4220
|
+
*/
|
|
4221
|
+
getLastHit(groupId) {
|
|
4222
|
+
const history = this.groupData.get(groupId);
|
|
4223
|
+
return history?.length ? history[history.length - 1] : null;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
/**
|
|
4227
|
+
* Get milliseconds since the last hit for a group.
|
|
4228
|
+
* @param {string} groupId
|
|
4229
|
+
* @returns {number|null}
|
|
4230
|
+
*/
|
|
4231
|
+
getTimeSinceLastHit(groupId) {
|
|
4232
|
+
const last = this.getLastHit(groupId);
|
|
4233
|
+
return last !== null ? Date.now() - last : null;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
/**
|
|
4237
|
+
* Internal utility to compute average spacing
|
|
4238
|
+
* @private
|
|
4239
|
+
* @param {number[]|undefined} history
|
|
4240
|
+
* @returns {number|null}
|
|
4241
|
+
*/
|
|
4242
|
+
_calculateAverageSpacing(history) {
|
|
4243
|
+
if (!Array.isArray(history) || history.length < 2) return null;
|
|
4244
|
+
let total = 0;
|
|
4245
|
+
for (let i = 1; i < history.length; i++) {
|
|
4246
|
+
total += history[i] - history[i - 1];
|
|
4247
|
+
}
|
|
4248
|
+
return total / (history.length - 1);
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
/**
|
|
4252
|
+
* Get average time between hits for a group (ms).
|
|
4253
|
+
* @param {string} groupId
|
|
4254
|
+
* @returns {number|null}
|
|
4255
|
+
*/
|
|
4256
|
+
getAverageHitSpacing(groupId) {
|
|
4257
|
+
return this._calculateAverageSpacing(this.groupData.get(groupId));
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
/**
|
|
4261
|
+
* Get metrics about a group's activity.
|
|
4262
|
+
* @param {string} groupId
|
|
4263
|
+
* @returns {{
|
|
4264
|
+
* totalHits: number,
|
|
4265
|
+
* lastHit: number|null,
|
|
4266
|
+
* timeSinceLastHit: number|null,
|
|
4267
|
+
* averageHitSpacing: number|null
|
|
4268
|
+
* }}
|
|
4269
|
+
*/
|
|
4270
|
+
getMetrics(groupId) {
|
|
4271
|
+
const history = this.groupData.get(groupId);
|
|
4272
|
+
|
|
4273
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
4274
|
+
return {
|
|
4275
|
+
totalHits: 0,
|
|
4276
|
+
lastHit: null,
|
|
4277
|
+
timeSinceLastHit: null,
|
|
4278
|
+
averageHitSpacing: null,
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
const totalHits = history.length;
|
|
4283
|
+
const lastHit = history[totalHits - 1];
|
|
4284
|
+
const timeSinceLastHit = Date.now() - lastHit;
|
|
4285
|
+
const averageHitSpacing = this._calculateAverageSpacing(history);
|
|
4286
|
+
|
|
4287
|
+
return {
|
|
4288
|
+
totalHits,
|
|
4289
|
+
lastHit,
|
|
4290
|
+
timeSinceLastHit,
|
|
4291
|
+
averageHitSpacing,
|
|
4292
|
+
};
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
/**
|
|
4296
|
+
* Destroy the rate limiter, stopping cleanup and clearing data
|
|
3927
4297
|
*/
|
|
3928
4298
|
destroy() {
|
|
3929
|
-
if (this
|
|
3930
|
-
this.
|
|
4299
|
+
if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
|
|
4300
|
+
this._cleanup();
|
|
4301
|
+
this.groupData.clear();
|
|
3931
4302
|
this.lastSeen.clear();
|
|
4303
|
+
this.userToGroup.clear();
|
|
4304
|
+
this.groupTTL.clear();
|
|
4305
|
+
this.groupFlags.clear();
|
|
3932
4306
|
}
|
|
3933
4307
|
}
|
|
3934
4308
|
|