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.
@@ -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
- * @class
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
- this.maxHits = isMaxHitsValid ? maxHits : null;
41
- this.interval = isIntervalValid ? interval : null;
42
- this.cleanupInterval = isCleanupValid ? cleanupInterval : null;
43
- this.maxIdle = maxIdle;
44
-
45
- /** @type {Map<string, number[]>} */
46
- this.userData = new Map();
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
- /** @type {Map<string, number>} */
49
- this.lastSeen = new Map();
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.cleanupInterval !== null)
53
- this._cleanupTimer = setInterval(() => this._cleanup(), this.cleanupInterval);
139
+ if (this.#cleanupInterval !== null)
140
+ this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
54
141
  }
55
142
 
56
143
  /**
57
- * Get the interval window in milliseconds.
58
- *
59
- * @returns {number} The interval value.
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
- getInterval() {
63
- if (typeof this.interval !== 'number' || !Number.isFinite(this.interval))
64
- throw new Error("'interval' is not a valid finite number.");
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 the maximum number of allowed hits.
70
- *
71
- * @returns {number} The maxHits value.
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
- getMaxHits() {
75
- if (typeof this.maxHits !== 'number' || !Number.isFinite(this.maxHits)) {
76
- throw new Error("'maxHits' is not a valid finite number.");
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
- return this.maxHits;
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.userData.has(userId)) {
89
- this.userData.set(userId, []);
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.userData.get(userId);
93
- if (!history) throw new Error(`No data found for userId: ${userId}`);
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(userId, now);
267
+ this.lastSeen.set(groupId, now);
97
268
 
98
269
  // Clean up old entries
99
- if (this.interval !== null) {
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.maxHits !== null) {
109
- const maxHits = this.getMaxHits();
110
- if (history.length > maxHits) {
111
- history.splice(0, history.length - maxHits);
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 now = Date.now();
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.userData.get(userId);
127
- if (!history) throw new Error(`No data found for userId: ${userId}`);
296
+ const history = this.groupData.get(groupId);
297
+ if (!history) throw new Error(`No data found for groupId: ${groupId}`);
128
298
 
129
- if (this.interval !== null) {
299
+ if (this.#interval !== null) {
300
+ const now = Date.now();
130
301
  const interval = this.getInterval();
131
- const recent = history.filter((t) => t > now - interval);
132
- if (this.maxHits !== null) {
133
- return recent.length >= this.getMaxHits();
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 recent.length > 0;
308
+ if (this.#maxHits !== null) return count > this.getMaxHits();
309
+ return count > 0;
136
310
  }
137
311
 
138
- if (this.maxHits !== null) {
139
- return history.length >= this.getMaxHits();
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 user data
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
- this.userData.delete(userId);
151
- this.lastSeen.delete(userId);
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
- * Set hit timestamps for a user
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(userId, timestamps) {
160
- this.userData.set(userId, timestamps);
161
- this.lastSeen.set(userId, Date.now());
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
- * Get timestamps from user
166
- * @param {string} userId
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(userId) {
170
- return this.userData.get(userId) || [];
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
- * Cleanup old/inactive users
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 [userId, last] of this.lastSeen.entries()) {
180
- if (now - last > this.maxIdle) {
181
- this.userData.delete(userId);
182
- this.lastSeen.delete(userId);
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
- * Destroy the rate limiter, stopping all intervals
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._cleanupTimer) clearInterval(this._cleanupTimer);
192
- this.userData.clear();
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