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,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 can be configured by maximum number of hits,
5
- * time interval, or a combination of both. It supports automatic
6
- * cleanup of inactive users to optimize memory usage.
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
- this.maxHits = isMaxHitsValid ? maxHits : null;
36
- this.interval = isIntervalValid ? interval : null;
37
- this.cleanupInterval = isCleanupValid ? cleanupInterval : null;
38
- this.maxIdle = maxIdle;
39
- /** @type {Map<string, number[]>} */
40
- this.userData = new Map();
41
- /** @type {Map<string, number>} */
42
- this.lastSeen = new Map();
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.cleanupInterval !== null)
45
- this._cleanupTimer = setInterval(() => this._cleanup(), this.cleanupInterval);
117
+ if (this.#cleanupInterval !== null)
118
+ this.#cleanupTimer = setInterval(() => this._cleanup(), this.#cleanupInterval);
46
119
  }
47
120
  /**
48
- * Get the interval window in milliseconds.
49
- *
50
- * @returns {number} The interval value.
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
- getInterval() {
54
- if (typeof this.interval !== 'number' || !Number.isFinite(this.interval))
55
- throw new Error("'interval' is not a valid finite number.");
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 the maximum number of allowed hits.
60
- *
61
- * @returns {number} The maxHits value.
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
- getMaxHits() {
65
- if (typeof this.maxHits !== 'number' || !Number.isFinite(this.maxHits)) {
66
- throw new Error("'maxHits' is not a valid finite number.");
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
- return this.maxHits;
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.userData.has(userId)) {
77
- this.userData.set(userId, []);
228
+ if (!this.groupData.has(groupId)) {
229
+ this.groupData.set(groupId, []);
230
+ this.groupFlags.set(groupId, false);
78
231
  }
79
- const history = this.userData.get(userId);
232
+ const history = this.groupData.get(groupId);
80
233
  if (!history)
81
- throw new Error(`No data found for userId: ${userId}`);
234
+ throw new Error(`No data found for groupId: ${groupId}`);
82
235
  history.push(now);
83
- this.lastSeen.set(userId, now);
236
+ this.lastSeen.set(groupId, now);
84
237
  // Clean up old entries
85
- if (this.interval !== null) {
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.maxHits !== null) {
94
- const maxHits = this.getMaxHits();
95
- if (history.length > maxHits) {
96
- history.splice(0, history.length - maxHits);
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 now = Date.now();
107
- if (!this.userData.has(userId))
260
+ const groupId = this.getGroupId(userId);
261
+ if (!this.groupData.has(groupId))
108
262
  return false;
109
- const history = this.userData.get(userId);
263
+ const history = this.groupData.get(groupId);
110
264
  if (!history)
111
- throw new Error(`No data found for userId: ${userId}`);
112
- if (this.interval !== null) {
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 recent = history.filter((t) => t > now - interval);
115
- if (this.maxHits !== null) {
116
- return recent.length >= this.getMaxHits();
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
- return recent.length > 0;
277
+ if (this.#maxHits !== null)
278
+ return count > this.getMaxHits();
279
+ return count > 0;
119
280
  }
120
- if (this.maxHits !== null) {
121
- return history.length >= this.getMaxHits();
281
+ if (this.#maxHits !== null) {
282
+ return history.length > this.getMaxHits();
122
283
  }
123
284
  return false;
124
285
  }
125
286
  /**
126
- * Manually reset user data
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
- this.userData.delete(userId);
131
- this.lastSeen.delete(userId);
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
- * Set hit timestamps for a user
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(userId, timestamps) {
139
- this.userData.set(userId, timestamps);
140
- this.lastSeen.set(userId, Date.now());
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
- * Get timestamps from user
144
- * @param {string} userId
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(userId) {
148
- return this.userData.get(userId) || [];
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 users
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 [userId, last] of this.lastSeen.entries()) {
157
- if (now - last > this.maxIdle) {
158
- this.userData.delete(userId);
159
- this.lastSeen.delete(userId);
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
- * Destroy the rate limiter, stopping all intervals
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._cleanupTimer)
168
- clearInterval(this._cleanupTimer);
169
- this.userData.clear();
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;
@@ -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
  ---