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