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