tiny-essentials 1.9.1 → 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.
@@ -2727,6 +2727,8 @@ var buffer = __webpack_require__(287);
2727
2727
  ;// ./src/v1/basics/objFilter.mjs
2728
2728
 
2729
2729
 
2730
+ const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
2731
+
2730
2732
  /**
2731
2733
  * An object containing type validation functions and their evaluation order.
2732
2734
  *
@@ -2736,111 +2738,27 @@ var buffer = __webpack_require__(287);
2736
2738
  * The `order` array defines the priority in which types should be checked,
2737
2739
  * which can be useful for functions that infer types in a consistent manner.
2738
2740
  *
2739
- * @typedef {Object} TypeValidator
2740
- * @property {Object.<string, (val: any) => boolean>} items - A dictionary of type validation functions.
2741
- * @property {string[]} order - The order in which types should be evaluated.
2742
- */
2743
-
2744
- /**
2745
- * Validates values against specific types using predefined functions.
2746
- *
2747
- * @type {TypeValidator}
2748
2741
  */
2749
2742
  const typeValidator = {
2750
- items: {
2751
- /** @param {*} val @returns {val is undefined} */
2752
- undefined: (val) => typeof val === 'undefined',
2753
-
2754
- /** @param {*} val @returns {val is null} */
2755
- null: (val) => val === null,
2756
-
2757
- /** @param {*} val @returns {val is boolean} */
2758
- boolean: (val) => typeof val === 'boolean',
2759
-
2760
- /** @param {*} val @returns {val is number} */
2761
- number: (val) => typeof val === 'number' && !isNaN(val),
2762
-
2763
- /** @param {*} val @returns {val is bigint} */
2764
- bigint: (val) => typeof val === 'bigint',
2765
-
2766
- /** @param {*} val @returns {val is string} */
2767
- string: (val) => typeof val === 'string',
2768
-
2769
- /** @param {*} val @returns {val is symbol} */
2770
- symbol: (val) => typeof val === 'symbol',
2771
-
2772
- /** @param {*} val @returns {val is Function} */
2773
- function: (val) => typeof val === 'function',
2774
-
2775
- /** @param {*} val @returns {val is Array} */
2776
- array: (val) => Array.isArray(val),
2777
-
2778
- /** @param {*} val @returns {val is Date} */
2779
- date: (val) => val instanceof Date,
2780
-
2781
- /** @param {*} val @returns {val is RegExp} */
2782
- regexp: (val) => val instanceof RegExp,
2783
-
2784
- /** @param {*} val @returns {val is Map} */
2785
- map: (val) => val instanceof Map,
2786
-
2787
- /** @param {*} val @returns {val is Set} */
2788
- set: (val) => val instanceof Set,
2789
-
2790
- /** @param {*} val @returns {val is WeakMap} */
2791
- weakmap: (val) => val instanceof WeakMap,
2792
-
2793
- /** @param {*} val @returns {val is WeakSet} */
2794
- weakset: (val) => val instanceof WeakSet,
2795
-
2796
- /** @param {*} val @returns {val is Promise} */
2797
- promise: (val) => val instanceof Promise,
2798
-
2799
- /** @param {*} val @returns {val is Buffer} */
2800
- buffer: (val) => typeof buffer/* Buffer */.hp !== 'undefined' && buffer/* Buffer */.hp.isBuffer(val),
2801
-
2802
- /** @param {*} val @returns {val is File} */
2803
- file: (val) => typeof File !== 'undefined' && val instanceof File,
2804
-
2805
- /** @param {*} val @returns {val is HTMLElement} */
2806
- htmlelement: (val) => typeof HTMLElement !== 'undefined' && val instanceof HTMLElement,
2807
-
2808
- /** @param {*} val @returns {val is object} */
2809
- object: (val) => typeof val === 'object' && val !== null && !Array.isArray(val),
2810
- },
2811
-
2812
- /** Evaluation order of the type checkers. */
2813
- order: [
2814
- 'undefined',
2815
- 'null',
2816
- 'boolean',
2817
- 'number',
2818
- 'bigint',
2819
- 'string',
2820
- 'symbol',
2821
- 'function',
2822
- 'array',
2823
- 'buffer',
2824
- 'file',
2825
- 'date',
2826
- 'regexp',
2827
- 'map',
2828
- 'set',
2829
- 'weakmap',
2830
- 'weakset',
2831
- 'promise',
2832
- 'htmlelement',
2833
- 'object',
2834
- ],
2743
+ items: {},
2744
+ /**
2745
+ * Evaluation order of the type checkers.
2746
+ * @type {string[]}
2747
+ * */
2748
+ order: [],
2835
2749
  };
2836
2750
 
2751
+ /** @typedef {Object.<string, (val: any) => *>} ExtendObjType */
2752
+ /** @typedef {Array<[string, (val: any) => *]>} ExtendObjTypeArray */
2753
+
2837
2754
  /**
2838
2755
  * Adds new type checkers to the typeValidator without overwriting existing ones.
2839
2756
  *
2840
- * Optionally, you can specify the index at which the new type should be inserted in the order.
2757
+ * Accepts either an object with named functions or an array of [key, fn] arrays.
2841
2758
  * If no index is provided, the type is inserted just before 'object' (if it exists), or at the end.
2842
2759
  *
2843
- * @param {Object.<string, (val: any) => boolean>} newItems - New type validators to be added.
2760
+ * @param {ExtendObjType|ExtendObjTypeArray} newItems
2761
+ * - New type validators to be added.
2844
2762
  * @param {number} [index] - Optional. Position at which to insert each new type. Ignored if the type already exists.
2845
2763
  * @returns {string[]} - A list of successfully added type names.
2846
2764
  *
@@ -2848,12 +2766,20 @@ const typeValidator = {
2848
2766
  * extendObjType({
2849
2767
  * htmlElement2: val => typeof HTMLElement !== 'undefined' && val instanceof HTMLElement
2850
2768
  * });
2769
+ *
2770
+ * @example
2771
+ * extendObjType([
2772
+ * ['alpha', val => typeof val === 'string'],
2773
+ * ['beta', val => Array.isArray(val)]
2774
+ * ]);
2851
2775
  */
2852
2776
  function extendObjType(newItems, index) {
2853
2777
  const added = [];
2854
2778
 
2855
- for (const [key, fn] of Object.entries(newItems)) {
2779
+ const entries = Array.isArray(newItems) ? newItems : Object.entries(newItems);
2780
+ for (const [key, fn] of entries) {
2856
2781
  if (!typeValidator.items.hasOwnProperty(key)) {
2782
+ // @ts-ignore
2857
2783
  typeValidator.items[key] = fn;
2858
2784
 
2859
2785
  let insertAt = typeof index === 'number' ? index : -1; // Default to -1 if index isn't provided
@@ -2931,9 +2857,12 @@ function cloneObjTypeOrder() {
2931
2857
  */
2932
2858
  const getType = (val) => {
2933
2859
  if (val === null) return 'null';
2934
- for (const name of typeValidator.order)
2860
+ // @ts-ignore
2861
+ for (const name of typeValidator.order) {
2862
+ // @ts-ignore
2935
2863
  if (typeof typeValidator.items[name] !== 'function' || typeValidator.items[name](val))
2936
2864
  return name;
2865
+ }
2937
2866
  return 'unknown';
2938
2867
  };
2939
2868
 
@@ -2968,7 +2897,9 @@ function checkObj(obj) {
2968
2897
  /** @type {{ valid:*; type: string | null }} */
2969
2898
  const data = { valid: null, type: null };
2970
2899
  for (const name of typeValidator.order) {
2900
+ // @ts-ignore
2971
2901
  if (typeof typeValidator.items[name] === 'function') {
2902
+ // @ts-ignore
2972
2903
  const result = typeValidator.items[name](obj);
2973
2904
  if (result) {
2974
2905
  data.valid = result;
@@ -3008,6 +2939,132 @@ function countObj(obj) {
3008
2939
  return 0;
3009
2940
  }
3010
2941
 
2942
+ // Insert obj types
2943
+
2944
+ extendObjType([
2945
+ [
2946
+ 'undefined',
2947
+ /** @param {*} val @returns {val is undefined} */
2948
+ (val) => typeof val === 'undefined',
2949
+ ],
2950
+ [
2951
+ 'null',
2952
+ /** @param {*} val @returns {val is null} */
2953
+ (val) => val === null,
2954
+ ],
2955
+ [
2956
+ 'boolean',
2957
+ /** @param {*} val @returns {val is boolean} */
2958
+ (val) => typeof val === 'boolean',
2959
+ ],
2960
+ [
2961
+ 'number',
2962
+ /** @param {*} val @returns {val is number} */
2963
+ (val) => typeof val === 'number' && !Number.isNaN(val),
2964
+ ],
2965
+ [
2966
+ 'bigint',
2967
+ /** @param {*} val @returns {val is bigint} */
2968
+ (val) => typeof val === 'bigint',
2969
+ ],
2970
+ [
2971
+ 'string',
2972
+ /** @param {*} val @returns {val is string} */
2973
+ (val) => typeof val === 'string',
2974
+ ],
2975
+ [
2976
+ 'symbol',
2977
+ /** @param {*} val @returns {val is symbol} */
2978
+ (val) => typeof val === 'symbol',
2979
+ ],
2980
+ [
2981
+ 'function',
2982
+ /** @param {*} val @returns {val is Function} */
2983
+ (val) => typeof val === 'function',
2984
+ ],
2985
+ [
2986
+ 'array',
2987
+ /** @param {*} val @returns {val is any[]} */
2988
+ (val) => Array.isArray(val),
2989
+ ],
2990
+ ]);
2991
+
2992
+ if (!isBrowser) {
2993
+ extendObjType([
2994
+ [
2995
+ 'buffer',
2996
+ /** @param {*} val @returns {val is Buffer} */
2997
+ (val) => typeof buffer/* Buffer */.hp !== 'undefined' && buffer/* Buffer */.hp.isBuffer(val),
2998
+ ],
2999
+ ]);
3000
+ }
3001
+
3002
+ if (isBrowser) {
3003
+ extendObjType([
3004
+ [
3005
+ 'file',
3006
+ /** @param {*} val @returns {val is File} */
3007
+ (val) => typeof File !== 'undefined' && val instanceof File,
3008
+ ],
3009
+ ]);
3010
+ }
3011
+
3012
+ extendObjType([
3013
+ [
3014
+ 'date',
3015
+ /** @param {*} val @returns {val is Date} */
3016
+ (val) => val instanceof Date,
3017
+ ],
3018
+ [
3019
+ 'regexp',
3020
+ /** @param {*} val @returns {val is RegExp} */
3021
+ (val) => val instanceof RegExp,
3022
+ ],
3023
+ [
3024
+ 'map',
3025
+ /** @param {*} val @returns {val is Map<any, any>} */
3026
+ (val) => val instanceof Map,
3027
+ ],
3028
+ [
3029
+ 'set',
3030
+ /** @param {*} val @returns {val is Set<any>} */
3031
+ (val) => val instanceof Set,
3032
+ ],
3033
+ [
3034
+ 'weakmap',
3035
+ /** @param {*} val @returns {val is WeakMap<any, any>} */
3036
+ (val) => val instanceof WeakMap,
3037
+ ],
3038
+ [
3039
+ 'weakset',
3040
+ /** @param {*} val @returns {val is WeakSet<any>} */
3041
+ (val) => val instanceof WeakSet,
3042
+ ],
3043
+ [
3044
+ 'promise',
3045
+ /** @param {*} val @returns {val is Promise<any>} */
3046
+ (val) => val instanceof Promise,
3047
+ ],
3048
+ ]);
3049
+
3050
+ if (isBrowser) {
3051
+ extendObjType([
3052
+ [
3053
+ 'htmlelement',
3054
+ /** @param {*} val @returns {val is HTMLElement} */
3055
+ (val) => typeof HTMLElement !== 'undefined' && val instanceof HTMLElement,
3056
+ ],
3057
+ ]);
3058
+ }
3059
+
3060
+ extendObjType([
3061
+ [
3062
+ 'object',
3063
+ /** @param {*} val @returns {val is object} */
3064
+ (val) => typeof val === 'object' && val !== null && !Array.isArray(val),
3065
+ ],
3066
+ ]);
3067
+
3011
3068
  ;// ./src/v1/basics/simpleMath.mjs
3012
3069
  /**
3013
3070
  * Executes a Rule of Three calculation.
@@ -3681,24 +3738,109 @@ class TinyPromiseQueue {
3681
3738
  /* harmony default export */ const libs_TinyPromiseQueue = (TinyPromiseQueue);
3682
3739
 
3683
3740
  ;// ./src/v1/libs/TinyRateLimiter.mjs
3741
+ /** @typedef {(groupId: string) => void} OnMemoryExceeded */
3742
+
3743
+ /** @typedef {(groupId: string) => void} OnGroupExpired */
3744
+
3684
3745
  /**
3685
- * Class representing a flexible rate limiter per user.
3746
+ * Class representing a flexible rate limiter per user or group.
3686
3747
  *
3687
- * This rate limiter can be configured by maximum number of hits,
3688
- * time interval, or a combination of both. It supports automatic
3689
- * cleanup of inactive users to optimize memory usage.
3690
- *
3691
- * @class
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.
3692
3751
  */
3693
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
+
3694
3835
  /**
3695
3836
  * @param {Object} options
3837
+ * @param {number|null} [options.maxMemory] - Max memory allowed
3696
3838
  * @param {number} [options.maxHits] - Max interactions allowed
3697
3839
  * @param {number} [options.interval] - Time window in milliseconds
3698
3840
  * @param {number} [options.cleanupInterval] - Interval for automatic cleanup (ms)
3699
3841
  * @param {number} [options.maxIdle=300000] - Max idle time for a user before being cleaned (ms)
3700
3842
  */
3701
- constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000 }) {
3843
+ constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) {
3702
3844
  /** @param {number|undefined} val */
3703
3845
  const isPositiveInteger = (val) =>
3704
3846
  typeof val === 'number' && Number.isFinite(val) && val >= 1 && Number.isInteger(val);
@@ -3718,45 +3860,129 @@ class TinyRateLimiter {
3718
3860
  throw new Error("'cleanupInterval' must be a positive integer in milliseconds if defined.");
3719
3861
  if (!isMaxIdleValid) throw new Error("'maxIdle' must be a positive integer in milliseconds.");
3720
3862
 
3721
- this.maxHits = isMaxHitsValid ? maxHits : null;
3722
- this.interval = isIntervalValid ? interval : null;
3723
- this.cleanupInterval = isCleanupValid ? cleanupInterval : null;
3724
- this.maxIdle = maxIdle;
3725
-
3726
- /** @type {Map<string, number[]>} */
3727
- this.userData = new Map();
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
+ }
3728
3870
 
3729
- /** @type {Map<string, number>} */
3730
- this.lastSeen = new Map();
3871
+ this.#maxHits = isMaxHitsValid ? maxHits : null;
3872
+ this.#interval = isIntervalValid ? interval : null;
3873
+ this.#cleanupInterval = isCleanupValid ? cleanupInterval : null;
3874
+ this.#maxIdle = maxIdle;
3731
3875
 
3732
3876
  // Start automatic cleanup only if cleanupInterval is valid
3733
- if (this.cleanupInterval !== null)
3734
- this._cleanupTimer = setInterval(() => this._cleanup(), this.cleanupInterval);
3877
+ if (this.#cleanupInterval !== null)
3878
+ this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
3735
3879
  }
3736
3880
 
3737
3881
  /**
3738
- * Get the interval window in milliseconds.
3739
- *
3740
- * @returns {number} The interval value.
3741
- * @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}
3742
3885
  */
3743
- getInterval() {
3744
- if (typeof this.interval !== 'number' || !Number.isFinite(this.interval))
3745
- throw new Error("'interval' is not a valid finite number.");
3746
- return this.interval;
3886
+ isGroupId(id) {
3887
+ const result = this.groupFlags.get(id);
3888
+ return typeof result === 'boolean' ? result : false;
3747
3889
  }
3748
3890
 
3749
3891
  /**
3750
- * Get the maximum number of allowed hits.
3751
- *
3752
- * @returns {number} The maxHits value.
3753
- * @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[]}
3754
3895
  */
3755
- getMaxHits() {
3756
- if (typeof this.maxHits !== 'number' || !Number.isFinite(this.maxHits)) {
3757
- throw new Error("'maxHits' is not a valid finite number.");
3896
+ getUsersInGroup(groupId) {
3897
+ const users = [];
3898
+ for (const [userId, assignedGroup] of this.userToGroup.entries()) {
3899
+ if (assignedGroup === groupId) {
3900
+ users.push(userId);
3901
+ }
3758
3902
  }
3759
- return this.maxHits;
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);
3967
+ }
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
3760
3986
  }
3761
3987
 
3762
3988
  /**
@@ -3764,20 +3990,22 @@ class TinyRateLimiter {
3764
3990
  * @param {string} userId
3765
3991
  */
3766
3992
  hit(userId) {
3993
+ const groupId = this.getGroupId(userId);
3767
3994
  const now = Date.now();
3768
3995
 
3769
- if (!this.userData.has(userId)) {
3770
- this.userData.set(userId, []);
3996
+ if (!this.groupData.has(groupId)) {
3997
+ this.groupData.set(groupId, []);
3998
+ this.groupFlags.set(groupId, false);
3771
3999
  }
3772
4000
 
3773
- const history = this.userData.get(userId);
3774
- if (!history) throw new Error(`No data found for userId: ${userId}`);
4001
+ const history = this.groupData.get(groupId);
4002
+ if (!history) throw new Error(`No data found for groupId: ${groupId}`);
3775
4003
 
3776
4004
  history.push(now);
3777
- this.lastSeen.set(userId, now);
4005
+ this.lastSeen.set(groupId, now);
3778
4006
 
3779
4007
  // Clean up old entries
3780
- if (this.interval !== null) {
4008
+ if (this.#interval !== null) {
3781
4009
  const interval = this.getInterval();
3782
4010
  const cutoff = now - interval;
3783
4011
  while (history.length && history[0] < cutoff) {
@@ -3786,92 +4014,295 @@ class TinyRateLimiter {
3786
4014
  }
3787
4015
 
3788
4016
  // Optional: keep only the last N entries for memory optimization
3789
- if (this.maxHits !== null) {
3790
- const maxHits = this.getMaxHits();
3791
- if (history.length > maxHits) {
3792
- history.splice(0, history.length - maxHits);
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);
3793
4021
  }
3794
4022
  }
3795
4023
  }
3796
4024
 
3797
4025
  /**
3798
- * Check if the user is currently rate limited
4026
+ * Check if the user (via their group) is currently rate limited
3799
4027
  * @param {string} userId
3800
4028
  * @returns {boolean}
3801
4029
  */
3802
4030
  isRateLimited(userId) {
3803
- const now = Date.now();
3804
-
3805
- if (!this.userData.has(userId)) return false;
4031
+ const groupId = this.getGroupId(userId);
4032
+ if (!this.groupData.has(groupId)) return false;
3806
4033
 
3807
- const history = this.userData.get(userId);
3808
- if (!history) throw new Error(`No data found for userId: ${userId}`);
4034
+ const history = this.groupData.get(groupId);
4035
+ if (!history) throw new Error(`No data found for groupId: ${groupId}`);
3809
4036
 
3810
- if (this.interval !== null) {
4037
+ if (this.#interval !== null) {
4038
+ const now = Date.now();
3811
4039
  const interval = this.getInterval();
3812
- const recent = history.filter((t) => t > now - interval);
3813
- if (this.maxHits !== null) {
3814
- return recent.length >= this.getMaxHits();
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;
3815
4045
  }
3816
- return recent.length > 0;
4046
+ if (this.#maxHits !== null) return count > this.getMaxHits();
4047
+ return count > 0;
3817
4048
  }
3818
4049
 
3819
- if (this.maxHits !== null) {
3820
- return history.length >= this.getMaxHits();
4050
+ if (this.#maxHits !== null) {
4051
+ return history.length > this.getMaxHits();
3821
4052
  }
3822
4053
 
3823
4054
  return false;
3824
4055
  }
3825
4056
 
3826
4057
  /**
3827
- * Manually reset user data
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.
3828
4072
  * @param {string} userId
4073
+ * @returns {void}
3829
4074
  */
3830
4075
  reset(userId) {
3831
- this.userData.delete(userId);
3832
- this.lastSeen.delete(userId);
4076
+ if (false)
4077
+ {}
4078
+ return this.resetUserGroup(userId);
3833
4079
  }
3834
4080
 
3835
4081
  /**
3836
- * Set hit timestamps for a user
4082
+ * Manually reset a user mapping
3837
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
3838
4092
  * @param {number[]} timestamps
3839
4093
  */
3840
- setData(userId, timestamps) {
3841
- this.userData.set(userId, timestamps);
3842
- this.lastSeen.set(userId, Date.now());
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());
3843
4104
  }
3844
4105
 
3845
4106
  /**
3846
- * Get timestamps from user
3847
- * @param {string} userId
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
3848
4118
  * @returns {number[]}
3849
4119
  */
3850
- getData(userId) {
3851
- return this.userData.get(userId) || [];
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;
3852
4133
  }
3853
4134
 
3854
4135
  /**
3855
- * Cleanup old/inactive users
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;
4144
+ }
4145
+
4146
+ /**
4147
+ * Cleanup old/inactive groups with individual TTLs
3856
4148
  * @private
3857
4149
  */
3858
4150
  _cleanup() {
3859
4151
  const now = Date.now();
3860
- for (const [userId, last] of this.lastSeen.entries()) {
3861
- if (now - last > this.maxIdle) {
3862
- this.userData.delete(userId);
3863
- this.lastSeen.delete(userId);
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
+ }
3864
4164
  }
3865
4165
  }
3866
4166
  }
3867
4167
 
3868
4168
  /**
3869
- * Destroy the rate limiter, stopping all intervals
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
3870
4297
  */
3871
4298
  destroy() {
3872
- if (this._cleanupTimer) clearInterval(this._cleanupTimer);
3873
- this.userData.clear();
4299
+ if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
4300
+ this._cleanup();
4301
+ this.groupData.clear();
3874
4302
  this.lastSeen.clear();
4303
+ this.userToGroup.clear();
4304
+ this.groupTTL.clear();
4305
+ this.groupFlags.clear();
3875
4306
  }
3876
4307
  }
3877
4308