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.
- package/dist/TinyBasicsEs.js +155 -98
- package/dist/TinyBasicsEs.min.js +1 -1
- package/dist/TinyEssentials.js +605 -174
- package/dist/TinyEssentials.min.js +1 -1
- package/dist/TinyRateLimiter.js +450 -76
- package/dist/TinyRateLimiter.min.js +1 -1
- package/dist/legacy/firebase/mySQL.cjs +1 -1
- package/dist/v1/basics/objFilter.cjs +155 -98
- package/dist/v1/basics/objFilter.d.mts +16 -26
- package/dist/v1/basics/objFilter.mjs +146 -77
- 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 +7 -0
- package/docs/libs/TinyRateLimiter.md +167 -15
- package/package.json +1 -1
|
@@ -1,23 +1,108 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
/** @typedef {(groupId: string) => void} OnMemoryExceeded */
|
|
4
|
+
|
|
5
|
+
/** @typedef {(groupId: string) => void} OnGroupExpired */
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
|
-
* Class representing a flexible rate limiter per user.
|
|
5
|
-
*
|
|
6
|
-
* This rate limiter can be configured by maximum number of hits,
|
|
7
|
-
* time interval, or a combination of both. It supports automatic
|
|
8
|
-
* cleanup of inactive users to optimize memory usage.
|
|
8
|
+
* Class representing a flexible rate limiter per user or group.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* This rate limiter supports limiting per user or per group by mapping
|
|
11
|
+
* userIds to a common groupId. All users within the same group share
|
|
12
|
+
* rate limits.
|
|
11
13
|
*/
|
|
12
14
|
class TinyRateLimiter {
|
|
15
|
+
/** @type {number|null} */
|
|
16
|
+
#maxMemory = null;
|
|
17
|
+
|
|
18
|
+
/** @type {NodeJS.Timeout|null} */
|
|
19
|
+
#cleanupTimer = null;
|
|
20
|
+
|
|
21
|
+
/** @type {number|null|undefined} */
|
|
22
|
+
#maxHits = null;
|
|
23
|
+
|
|
24
|
+
/** @type {number|null|undefined} */
|
|
25
|
+
#interval = null;
|
|
26
|
+
|
|
27
|
+
/** @type {number|null|undefined} */
|
|
28
|
+
#cleanupInterval = null;
|
|
29
|
+
|
|
30
|
+
/** @type {number|null|undefined} */
|
|
31
|
+
#maxIdle = null;
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, number[]>} */
|
|
34
|
+
groupData = new Map(); // groupId -> timestamps[]
|
|
35
|
+
|
|
36
|
+
/** @type {Map<string, number>} */
|
|
37
|
+
lastSeen = new Map(); // groupId -> timestamp
|
|
38
|
+
|
|
39
|
+
/** @type {Map<string, string>} */
|
|
40
|
+
userToGroup = new Map(); // userId -> groupId
|
|
41
|
+
|
|
42
|
+
/** @type {Map<string, boolean>} */
|
|
43
|
+
groupFlags = new Map(); // groupId -> boolean
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @type {Map<string, number>}
|
|
47
|
+
* Stores TTL (in ms) for each groupId individually
|
|
48
|
+
*/
|
|
49
|
+
groupTTL = new Map();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @type {null|OnMemoryExceeded}
|
|
53
|
+
*/
|
|
54
|
+
#onMemoryExceeded = null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set the callback to be triggered when a group exceeds its limit
|
|
58
|
+
* @param {OnMemoryExceeded} callback
|
|
59
|
+
*/
|
|
60
|
+
setOnMemoryExceeded(callback) {
|
|
61
|
+
if (typeof callback !== 'function') throw new Error('onMemoryExceeded must be a function');
|
|
62
|
+
this.#onMemoryExceeded = callback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Clear the onMemoryExceeded callback
|
|
67
|
+
*/
|
|
68
|
+
clearOnMemoryExceeded() {
|
|
69
|
+
this.#onMemoryExceeded = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @type {null|OnGroupExpired}
|
|
74
|
+
*/
|
|
75
|
+
#onGroupExpired = null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the callback to be triggered when a group expires and is removed.
|
|
79
|
+
*
|
|
80
|
+
* This callback is called automatically during cleanup when a group
|
|
81
|
+
* becomes inactive for longer than its TTL.
|
|
82
|
+
*
|
|
83
|
+
* @param {OnGroupExpired} callback - A function that receives the expired groupId.
|
|
84
|
+
*/
|
|
85
|
+
setOnGroupExpired(callback) {
|
|
86
|
+
if (typeof callback !== 'function') throw new Error('onGroupExpired must be a function');
|
|
87
|
+
this.#onGroupExpired = callback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clear the onGroupExpired callback
|
|
92
|
+
*/
|
|
93
|
+
clearOnGroupExpired() {
|
|
94
|
+
this.#onGroupExpired = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
13
97
|
/**
|
|
14
98
|
* @param {Object} options
|
|
99
|
+
* @param {number|null} [options.maxMemory] - Max memory allowed
|
|
15
100
|
* @param {number} [options.maxHits] - Max interactions allowed
|
|
16
101
|
* @param {number} [options.interval] - Time window in milliseconds
|
|
17
102
|
* @param {number} [options.cleanupInterval] - Interval for automatic cleanup (ms)
|
|
18
103
|
* @param {number} [options.maxIdle=300000] - Max idle time for a user before being cleaned (ms)
|
|
19
104
|
*/
|
|
20
|
-
constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000 }) {
|
|
105
|
+
constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) {
|
|
21
106
|
/** @param {number|undefined} val */
|
|
22
107
|
const isPositiveInteger = (val) =>
|
|
23
108
|
typeof val === 'number' && Number.isFinite(val) && val >= 1 && Number.isInteger(val);
|
|
@@ -37,45 +122,129 @@ class TinyRateLimiter {
|
|
|
37
122
|
throw new Error("'cleanupInterval' must be a positive integer in milliseconds if defined.");
|
|
38
123
|
if (!isMaxIdleValid) throw new Error("'maxIdle' must be a positive integer in milliseconds.");
|
|
39
124
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
125
|
+
if (typeof maxMemory === 'number' && Number.isFinite(maxMemory) && maxMemory > 0) {
|
|
126
|
+
this.#maxMemory = Math.floor(maxMemory);
|
|
127
|
+
} else if (maxMemory === null || maxMemory === undefined) {
|
|
128
|
+
this.#maxMemory = null;
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error('maxMemory must be a positive number or null');
|
|
131
|
+
}
|
|
47
132
|
|
|
48
|
-
|
|
49
|
-
this
|
|
133
|
+
this.#maxHits = isMaxHitsValid ? maxHits : null;
|
|
134
|
+
this.#interval = isIntervalValid ? interval : null;
|
|
135
|
+
this.#cleanupInterval = isCleanupValid ? cleanupInterval : null;
|
|
136
|
+
this.#maxIdle = maxIdle;
|
|
50
137
|
|
|
51
138
|
// Start automatic cleanup only if cleanupInterval is valid
|
|
52
|
-
if (this
|
|
53
|
-
this
|
|
139
|
+
if (this.#cleanupInterval !== null)
|
|
140
|
+
this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
|
|
54
141
|
}
|
|
55
142
|
|
|
56
143
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @returns {
|
|
60
|
-
* @throws {Error} If interval is not a valid finite number.
|
|
144
|
+
* Check if a given ID is a groupId (not a userId)
|
|
145
|
+
* @param {string} id
|
|
146
|
+
* @returns {boolean}
|
|
61
147
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return this.interval;
|
|
148
|
+
isGroupId(id) {
|
|
149
|
+
const result = this.groupFlags.get(id);
|
|
150
|
+
return typeof result === 'boolean' ? result : false;
|
|
66
151
|
}
|
|
67
152
|
|
|
68
153
|
/**
|
|
69
|
-
* Get
|
|
70
|
-
*
|
|
71
|
-
* @returns {
|
|
72
|
-
* @throws {Error} If maxHits is not a valid finite number.
|
|
154
|
+
* Get all user IDs that belong to a given group.
|
|
155
|
+
* @param {string} groupId
|
|
156
|
+
* @returns {string[]}
|
|
73
157
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
158
|
+
getUsersInGroup(groupId) {
|
|
159
|
+
const users = [];
|
|
160
|
+
for (const [userId, assignedGroup] of this.userToGroup.entries()) {
|
|
161
|
+
if (assignedGroup === groupId) {
|
|
162
|
+
users.push(userId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return users;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Set TTL (in milliseconds) for a specific group
|
|
171
|
+
* @param {string} groupId
|
|
172
|
+
* @param {number} ttl
|
|
173
|
+
*/
|
|
174
|
+
setGroupTTL(groupId, ttl) {
|
|
175
|
+
if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl <= 0)
|
|
176
|
+
throw new Error('TTL must be a positive number in milliseconds');
|
|
177
|
+
this.groupTTL.set(groupId, ttl);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get TTL (in ms) for a specific group.
|
|
182
|
+
* @param {string} groupId
|
|
183
|
+
* @returns {number|null}
|
|
184
|
+
*/
|
|
185
|
+
getGroupTTL(groupId) {
|
|
186
|
+
return this.groupTTL.get(groupId) ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Delete the TTL setting for a specific group
|
|
191
|
+
* @param {string} groupId
|
|
192
|
+
*/
|
|
193
|
+
deleteGroupTTL(groupId) {
|
|
194
|
+
this.groupTTL.delete(groupId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Assign a userId to a groupId, with merge if user has existing data.
|
|
199
|
+
* @param {string} userId
|
|
200
|
+
* @param {string} groupId
|
|
201
|
+
* @throws {Error} If userId is already assigned to a different group
|
|
202
|
+
*/
|
|
203
|
+
assignToGroup(userId, groupId) {
|
|
204
|
+
const existingGroup = this.userToGroup.get(userId);
|
|
205
|
+
if (existingGroup && existingGroup !== groupId)
|
|
206
|
+
throw new Error(`User ${userId} is already assigned to group ${existingGroup}`);
|
|
207
|
+
|
|
208
|
+
// If the user is already in the group, nothing needs to be done
|
|
209
|
+
if (existingGroup === groupId) return;
|
|
210
|
+
const userData = this.groupData.get(userId);
|
|
211
|
+
|
|
212
|
+
// Associates the user to the group
|
|
213
|
+
if (this.isGroupId(userId)) {
|
|
214
|
+
for (const [uid, gId] of this.userToGroup.entries())
|
|
215
|
+
if (gId === userId) this.userToGroup.set(uid, groupId);
|
|
216
|
+
this.userToGroup.delete(userId);
|
|
217
|
+
} else this.userToGroup.set(userId, groupId);
|
|
218
|
+
|
|
219
|
+
// If the user has no data, nothing needs to be done
|
|
220
|
+
if (!userData) return;
|
|
221
|
+
|
|
222
|
+
const groupData = this.groupData.get(groupId);
|
|
223
|
+
if (groupData) {
|
|
224
|
+
for (const item of userData) groupData.push(item);
|
|
225
|
+
} else {
|
|
226
|
+
const newData = [];
|
|
227
|
+
for (const item of userData) newData.push(item);
|
|
228
|
+
this.groupData.set(groupId, newData);
|
|
77
229
|
}
|
|
78
|
-
|
|
230
|
+
|
|
231
|
+
this.lastSeen.set(groupId, Date.now());
|
|
232
|
+
|
|
233
|
+
// Removes individual data as they are now in the group
|
|
234
|
+
this.groupFlags.delete(userId);
|
|
235
|
+
this.groupData.delete(userId);
|
|
236
|
+
this.lastSeen.delete(userId);
|
|
237
|
+
this.groupTTL.delete(userId);
|
|
238
|
+
this.groupFlags.set(groupId, true);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get the groupId for a given userId
|
|
243
|
+
* @param {string} userId
|
|
244
|
+
* @returns {string}
|
|
245
|
+
*/
|
|
246
|
+
getGroupId(userId) {
|
|
247
|
+
return this.userToGroup.get(userId) || userId; // fallback: use userId as own group
|
|
79
248
|
}
|
|
80
249
|
|
|
81
250
|
/**
|
|
@@ -83,20 +252,22 @@ class TinyRateLimiter {
|
|
|
83
252
|
* @param {string} userId
|
|
84
253
|
*/
|
|
85
254
|
hit(userId) {
|
|
255
|
+
const groupId = this.getGroupId(userId);
|
|
86
256
|
const now = Date.now();
|
|
87
257
|
|
|
88
|
-
if (!this.
|
|
89
|
-
this.
|
|
258
|
+
if (!this.groupData.has(groupId)) {
|
|
259
|
+
this.groupData.set(groupId, []);
|
|
260
|
+
this.groupFlags.set(groupId, false);
|
|
90
261
|
}
|
|
91
262
|
|
|
92
|
-
const history = this.
|
|
93
|
-
if (!history) throw new Error(`No data found for
|
|
263
|
+
const history = this.groupData.get(groupId);
|
|
264
|
+
if (!history) throw new Error(`No data found for groupId: ${groupId}`);
|
|
94
265
|
|
|
95
266
|
history.push(now);
|
|
96
|
-
this.lastSeen.set(
|
|
267
|
+
this.lastSeen.set(groupId, now);
|
|
97
268
|
|
|
98
269
|
// Clean up old entries
|
|
99
|
-
if (this
|
|
270
|
+
if (this.#interval !== null) {
|
|
100
271
|
const interval = this.getInterval();
|
|
101
272
|
const cutoff = now - interval;
|
|
102
273
|
while (history.length && history[0] < cutoff) {
|
|
@@ -105,92 +276,295 @@ class TinyRateLimiter {
|
|
|
105
276
|
}
|
|
106
277
|
|
|
107
278
|
// Optional: keep only the last N entries for memory optimization
|
|
108
|
-
if (this
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
279
|
+
if (this.#maxMemory !== null && typeof this.#maxMemory === 'number') {
|
|
280
|
+
if (history.length > this.#maxMemory) {
|
|
281
|
+
history.splice(0, history.length - this.#maxMemory);
|
|
282
|
+
if (typeof this.#onMemoryExceeded === 'function') this.#onMemoryExceeded(groupId);
|
|
112
283
|
}
|
|
113
284
|
}
|
|
114
285
|
}
|
|
115
286
|
|
|
116
287
|
/**
|
|
117
|
-
* Check if the user is currently rate limited
|
|
288
|
+
* Check if the user (via their group) is currently rate limited
|
|
118
289
|
* @param {string} userId
|
|
119
290
|
* @returns {boolean}
|
|
120
291
|
*/
|
|
121
292
|
isRateLimited(userId) {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
if (!this.userData.has(userId)) return false;
|
|
293
|
+
const groupId = this.getGroupId(userId);
|
|
294
|
+
if (!this.groupData.has(groupId)) return false;
|
|
125
295
|
|
|
126
|
-
const history = this.
|
|
127
|
-
if (!history) throw new Error(`No data found for
|
|
296
|
+
const history = this.groupData.get(groupId);
|
|
297
|
+
if (!history) throw new Error(`No data found for groupId: ${groupId}`);
|
|
128
298
|
|
|
129
|
-
if (this
|
|
299
|
+
if (this.#interval !== null) {
|
|
300
|
+
const now = Date.now();
|
|
130
301
|
const interval = this.getInterval();
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
302
|
+
const cutoff = now - interval;
|
|
303
|
+
let count = 0;
|
|
304
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
305
|
+
if (history[i] > cutoff) count++;
|
|
306
|
+
else break;
|
|
134
307
|
}
|
|
135
|
-
return
|
|
308
|
+
if (this.#maxHits !== null) return count > this.getMaxHits();
|
|
309
|
+
return count > 0;
|
|
136
310
|
}
|
|
137
311
|
|
|
138
|
-
if (this
|
|
139
|
-
return history.length
|
|
312
|
+
if (this.#maxHits !== null) {
|
|
313
|
+
return history.length > this.getMaxHits();
|
|
140
314
|
}
|
|
141
315
|
|
|
142
316
|
return false;
|
|
143
317
|
}
|
|
144
318
|
|
|
145
319
|
/**
|
|
146
|
-
* Manually reset
|
|
320
|
+
* Manually reset group data
|
|
321
|
+
* @param {string} groupId
|
|
322
|
+
*/
|
|
323
|
+
resetGroup(groupId) {
|
|
324
|
+
this.groupFlags.delete(groupId);
|
|
325
|
+
this.groupData.delete(groupId);
|
|
326
|
+
this.lastSeen.delete(groupId);
|
|
327
|
+
this.groupTTL.delete(groupId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Manually reset user data.
|
|
332
|
+
*
|
|
333
|
+
* @deprecated Use `resetUserGroup(userId)` instead. This method will be removed in future versions.
|
|
147
334
|
* @param {string} userId
|
|
335
|
+
* @returns {void}
|
|
148
336
|
*/
|
|
149
337
|
reset(userId) {
|
|
150
|
-
|
|
151
|
-
|
|
338
|
+
if (process?.env?.NODE_ENV !== 'production')
|
|
339
|
+
console.warn(`[TinyRateLimiter] 'reset()' is deprecated. Use 'resetUserGroup()' instead.`);
|
|
340
|
+
return this.resetUserGroup(userId);
|
|
152
341
|
}
|
|
153
342
|
|
|
154
343
|
/**
|
|
155
|
-
*
|
|
344
|
+
* Manually reset a user mapping
|
|
156
345
|
* @param {string} userId
|
|
346
|
+
*/
|
|
347
|
+
resetUserGroup(userId) {
|
|
348
|
+
this.userToGroup.delete(userId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Set custom timestamps to a group
|
|
353
|
+
* @param {string} groupId
|
|
157
354
|
* @param {number[]} timestamps
|
|
158
355
|
*/
|
|
159
|
-
setData(
|
|
160
|
-
|
|
161
|
-
|
|
356
|
+
setData(groupId, timestamps) {
|
|
357
|
+
if (!Array.isArray(timestamps)) throw new Error('timestamps must be an array of numbers.');
|
|
358
|
+
for (const t of timestamps) {
|
|
359
|
+
if (typeof t !== 'number' || !Number.isFinite(t)) {
|
|
360
|
+
throw new Error('All timestamps must be finite numbers.');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!this.groupData.has(groupId)) this.groupFlags.set(groupId, false);
|
|
364
|
+
this.groupData.set(groupId, timestamps);
|
|
365
|
+
this.lastSeen.set(groupId, Date.now());
|
|
162
366
|
}
|
|
163
367
|
|
|
164
368
|
/**
|
|
165
|
-
*
|
|
166
|
-
* @param {string}
|
|
369
|
+
* Check if a group has data
|
|
370
|
+
* @param {string} groupId
|
|
371
|
+
* @returns {boolean}
|
|
372
|
+
*/
|
|
373
|
+
hasData(groupId) {
|
|
374
|
+
return this.groupData.has(groupId);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get timestamps from a group
|
|
379
|
+
* @param {string} groupId
|
|
167
380
|
* @returns {number[]}
|
|
168
381
|
*/
|
|
169
|
-
getData(
|
|
170
|
-
return this.
|
|
382
|
+
getData(groupId) {
|
|
383
|
+
return this.groupData.get(groupId) || [];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get the maximum idle time (in milliseconds) before a group is considered expired.
|
|
388
|
+
* @returns {number}
|
|
389
|
+
*/
|
|
390
|
+
getMaxIdle() {
|
|
391
|
+
if (typeof this.#maxIdle !== 'number' || !Number.isFinite(this.#maxIdle) || this.#maxIdle < 0) {
|
|
392
|
+
throw new Error("'maxIdle' must be a non-negative finite number.");
|
|
393
|
+
}
|
|
394
|
+
return this.#maxIdle;
|
|
171
395
|
}
|
|
172
396
|
|
|
173
397
|
/**
|
|
174
|
-
*
|
|
398
|
+
* Set the maximum idle time (in milliseconds) before a group is considered expired.
|
|
399
|
+
* @param {number} ms
|
|
400
|
+
*/
|
|
401
|
+
setMaxIdle(ms) {
|
|
402
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
|
403
|
+
throw new Error("'maxIdle' must be a non-negative finite number.");
|
|
404
|
+
}
|
|
405
|
+
this.#maxIdle = ms;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Cleanup old/inactive groups with individual TTLs
|
|
175
410
|
* @private
|
|
176
411
|
*/
|
|
177
412
|
_cleanup() {
|
|
178
413
|
const now = Date.now();
|
|
179
|
-
for (const [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.
|
|
414
|
+
for (const [groupId, last] of this.lastSeen.entries()) {
|
|
415
|
+
const ttl = this.getGroupTTL(groupId) ?? this.getMaxIdle();
|
|
416
|
+
if (now - last > ttl) {
|
|
417
|
+
this.groupFlags.delete(groupId);
|
|
418
|
+
this.groupData.delete(groupId);
|
|
419
|
+
this.lastSeen.delete(groupId);
|
|
420
|
+
this.groupTTL.delete(groupId);
|
|
421
|
+
|
|
422
|
+
// Notify subclass or external binding
|
|
423
|
+
if (typeof this.#onGroupExpired === 'function') {
|
|
424
|
+
this.#onGroupExpired(groupId);
|
|
425
|
+
}
|
|
183
426
|
}
|
|
184
427
|
}
|
|
185
428
|
}
|
|
186
429
|
|
|
187
430
|
/**
|
|
188
|
-
*
|
|
431
|
+
* Get list of active group IDs
|
|
432
|
+
* @returns {string[]}
|
|
433
|
+
*/
|
|
434
|
+
getActiveGroups() {
|
|
435
|
+
return Array.from(this.groupData.keys());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get a shallow copy of all user-to-group mappings as a plain object
|
|
440
|
+
* @returns {Record<string, string>}
|
|
441
|
+
*/
|
|
442
|
+
getAllUserMappings() {
|
|
443
|
+
return Object.fromEntries(this.userToGroup);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get the interval window in milliseconds.
|
|
448
|
+
* @returns {number}
|
|
449
|
+
*/
|
|
450
|
+
getInterval() {
|
|
451
|
+
if (typeof this.#interval !== 'number' || !Number.isFinite(this.#interval)) {
|
|
452
|
+
throw new Error("'interval' is not a valid finite number.");
|
|
453
|
+
}
|
|
454
|
+
return this.#interval;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get the maximum number of allowed hits.
|
|
459
|
+
* @returns {number}
|
|
460
|
+
*/
|
|
461
|
+
getMaxHits() {
|
|
462
|
+
if (typeof this.#maxHits !== 'number' || !Number.isFinite(this.#maxHits)) {
|
|
463
|
+
throw new Error("'maxHits' is not a valid finite number.");
|
|
464
|
+
}
|
|
465
|
+
return this.#maxHits;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get the total number of hits recorded for a group.
|
|
470
|
+
* @param {string} groupId
|
|
471
|
+
* @returns {number}
|
|
472
|
+
*/
|
|
473
|
+
getTotalHits(groupId) {
|
|
474
|
+
const history = this.groupData.get(groupId);
|
|
475
|
+
return Array.isArray(history) ? history.length : 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Get the timestamp of the last hit for a group.
|
|
480
|
+
* @param {string} groupId
|
|
481
|
+
* @returns {number|null}
|
|
482
|
+
*/
|
|
483
|
+
getLastHit(groupId) {
|
|
484
|
+
const history = this.groupData.get(groupId);
|
|
485
|
+
return history?.length ? history[history.length - 1] : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Get milliseconds since the last hit for a group.
|
|
490
|
+
* @param {string} groupId
|
|
491
|
+
* @returns {number|null}
|
|
492
|
+
*/
|
|
493
|
+
getTimeSinceLastHit(groupId) {
|
|
494
|
+
const last = this.getLastHit(groupId);
|
|
495
|
+
return last !== null ? Date.now() - last : null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Internal utility to compute average spacing
|
|
500
|
+
* @private
|
|
501
|
+
* @param {number[]|undefined} history
|
|
502
|
+
* @returns {number|null}
|
|
503
|
+
*/
|
|
504
|
+
_calculateAverageSpacing(history) {
|
|
505
|
+
if (!Array.isArray(history) || history.length < 2) return null;
|
|
506
|
+
let total = 0;
|
|
507
|
+
for (let i = 1; i < history.length; i++) {
|
|
508
|
+
total += history[i] - history[i - 1];
|
|
509
|
+
}
|
|
510
|
+
return total / (history.length - 1);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Get average time between hits for a group (ms).
|
|
515
|
+
* @param {string} groupId
|
|
516
|
+
* @returns {number|null}
|
|
517
|
+
*/
|
|
518
|
+
getAverageHitSpacing(groupId) {
|
|
519
|
+
return this._calculateAverageSpacing(this.groupData.get(groupId));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get metrics about a group's activity.
|
|
524
|
+
* @param {string} groupId
|
|
525
|
+
* @returns {{
|
|
526
|
+
* totalHits: number,
|
|
527
|
+
* lastHit: number|null,
|
|
528
|
+
* timeSinceLastHit: number|null,
|
|
529
|
+
* averageHitSpacing: number|null
|
|
530
|
+
* }}
|
|
531
|
+
*/
|
|
532
|
+
getMetrics(groupId) {
|
|
533
|
+
const history = this.groupData.get(groupId);
|
|
534
|
+
|
|
535
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
536
|
+
return {
|
|
537
|
+
totalHits: 0,
|
|
538
|
+
lastHit: null,
|
|
539
|
+
timeSinceLastHit: null,
|
|
540
|
+
averageHitSpacing: null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const totalHits = history.length;
|
|
545
|
+
const lastHit = history[totalHits - 1];
|
|
546
|
+
const timeSinceLastHit = Date.now() - lastHit;
|
|
547
|
+
const averageHitSpacing = this._calculateAverageSpacing(history);
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
totalHits,
|
|
551
|
+
lastHit,
|
|
552
|
+
timeSinceLastHit,
|
|
553
|
+
averageHitSpacing,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Destroy the rate limiter, stopping cleanup and clearing data
|
|
189
559
|
*/
|
|
190
560
|
destroy() {
|
|
191
|
-
if (this
|
|
192
|
-
this.
|
|
561
|
+
if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
|
|
562
|
+
this._cleanup();
|
|
563
|
+
this.groupData.clear();
|
|
193
564
|
this.lastSeen.clear();
|
|
565
|
+
this.userToGroup.clear();
|
|
566
|
+
this.groupTTL.clear();
|
|
567
|
+
this.groupFlags.clear();
|
|
194
568
|
}
|
|
195
569
|
}
|
|
196
570
|
|