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