@stimulcross/rate-limiter 0.0.1

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.
Files changed (112) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/node.yml +87 -0
  3. package/.husky/commit-msg +1 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.megaignore +8 -0
  6. package/.prettierignore +3 -0
  7. package/LICENSE +21 -0
  8. package/README.md +7 -0
  9. package/commitlint.config.js +8 -0
  10. package/eslint.config.js +65 -0
  11. package/lint-staged.config.js +4 -0
  12. package/package.json +89 -0
  13. package/prettier.config.cjs +1 -0
  14. package/src/core/cancellable.ts +4 -0
  15. package/src/core/clock.ts +9 -0
  16. package/src/core/decision.ts +27 -0
  17. package/src/core/rate-limit-policy.ts +15 -0
  18. package/src/core/rate-limiter-status.ts +14 -0
  19. package/src/core/rate-limiter.ts +37 -0
  20. package/src/core/state-storage.ts +51 -0
  21. package/src/enums/rate-limit-error-code.ts +29 -0
  22. package/src/errors/custom.error.ts +14 -0
  23. package/src/errors/invalid-cost.error.ts +33 -0
  24. package/src/errors/rate-limit.error.ts +91 -0
  25. package/src/errors/rate-limiter-destroyed.error.ts +8 -0
  26. package/src/index.ts +11 -0
  27. package/src/interfaces/rate-limiter-options.ts +84 -0
  28. package/src/interfaces/rate-limiter-queue-options.ts +45 -0
  29. package/src/interfaces/rate-limiter-run-options.ts +58 -0
  30. package/src/limiters/abstract-rate-limiter.ts +206 -0
  31. package/src/limiters/composite.policy.ts +102 -0
  32. package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
  33. package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
  34. package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
  35. package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
  36. package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
  37. package/src/limiters/fixed-window/index.ts +4 -0
  38. package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
  39. package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
  40. package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
  41. package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
  42. package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
  43. package/src/limiters/generic-cell/index.ts +4 -0
  44. package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
  45. package/src/limiters/http-response-based/http-limit.info.ts +41 -0
  46. package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
  47. package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
  48. package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
  49. package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
  50. package/src/limiters/http-response-based/index.ts +6 -0
  51. package/src/limiters/leaky-bucket/index.ts +4 -0
  52. package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
  53. package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
  54. package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
  55. package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
  56. package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
  57. package/src/limiters/sliding-window-counter/index.ts +7 -0
  58. package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
  59. package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
  60. package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
  61. package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
  62. package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
  63. package/src/limiters/sliding-window-log/index.ts +4 -0
  64. package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
  65. package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
  66. package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
  67. package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
  68. package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
  69. package/src/limiters/token-bucket/index.ts +4 -0
  70. package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
  71. package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
  72. package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
  73. package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
  74. package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
  75. package/src/runtime/default-clock.ts +8 -0
  76. package/src/runtime/execution-tickets.ts +34 -0
  77. package/src/runtime/in-memory-state-store.ts +135 -0
  78. package/src/runtime/rate-limiter.executor.ts +286 -0
  79. package/src/runtime/semaphore.ts +31 -0
  80. package/src/runtime/task.ts +141 -0
  81. package/src/types/limit-behavior.ts +8 -0
  82. package/src/utils/generate-random-string.ts +16 -0
  83. package/src/utils/promise-with-resolvers.ts +23 -0
  84. package/src/utils/sanitize-error.ts +4 -0
  85. package/src/utils/sanitize-priority.ts +22 -0
  86. package/src/utils/validate-cost.ts +16 -0
  87. package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
  88. package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
  89. package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
  90. package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
  91. package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
  92. package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
  93. package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
  94. package/tests/tsconfig.json +4 -0
  95. package/tests/unit/policies/composite.policy.spec.ts +244 -0
  96. package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
  97. package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
  98. package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
  99. package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
  100. package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
  101. package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
  102. package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
  103. package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
  104. package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
  105. package/tests/unit/runtime/semaphore.spec.ts +98 -0
  106. package/tests/unit/runtime/task.spec.ts +182 -0
  107. package/tests/unit/utils/generate-random-string.spec.ts +51 -0
  108. package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
  109. package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
  110. package/tests/unit/utils/validate-cost.spec.ts +48 -0
  111. package/tsconfig.json +14 -0
  112. package/vitest.config.js +22 -0
@@ -0,0 +1,135 @@
1
+ import { Deque } from '@stimulcross/ds-deque';
2
+ import { type Clock } from '../core/clock.js';
3
+ import { type StateStorage } from '../core/state-storage.js';
4
+ import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
5
+ import { RateLimitError } from '../errors/rate-limit.error.js';
6
+ import { promiseWithResolvers } from '../utils/promise-with-resolvers.js';
7
+
8
+ interface StateEntry<V> {
9
+ value: V;
10
+ expiresAt?: number;
11
+ }
12
+
13
+ interface Waiter {
14
+ resolve: () => void;
15
+ reject: (err: Error) => void;
16
+ }
17
+
18
+ const CLEANUP_INTERVAL_MS = 60_000;
19
+
20
+ /** @internal */
21
+ export class InMemoryStateStore<V> implements StateStorage<V> {
22
+ private readonly _state = new Map<string, StateEntry<V>>();
23
+ private readonly _activeLocks = new Set<string>();
24
+ private readonly _waitingResolvers = new Map<string, Deque<Waiter>>();
25
+
26
+ private readonly _cleanupTimer: ReturnType<typeof setTimeout>;
27
+
28
+ constructor(private readonly _clock: Clock) {
29
+ this._cleanupTimer = setInterval(() => {
30
+ const now = this._clock.now();
31
+
32
+ for (const [key, entry] of this._state.entries()) {
33
+ if (entry.expiresAt && entry.expiresAt <= now) {
34
+ this._state.delete(key);
35
+ }
36
+ }
37
+ }, CLEANUP_INTERVAL_MS);
38
+
39
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
40
+ if (this._cleanupTimer.unref) {
41
+ this._cleanupTimer.unref();
42
+ }
43
+ }
44
+
45
+ public async get(key: string): Promise<V | null> {
46
+ const entry = this._state.get(key);
47
+
48
+ if (entry?.expiresAt && entry.expiresAt <= this._clock.now()) {
49
+ this._state.delete(key);
50
+ return null;
51
+ }
52
+
53
+ return entry?.value ?? null;
54
+ }
55
+
56
+ public async set(key: string, value: V, ttlMs?: number): Promise<void> {
57
+ const entry: StateEntry<V> = { value };
58
+
59
+ if (typeof ttlMs === 'number') {
60
+ if (ttlMs <= 0) {
61
+ this._state.delete(key);
62
+ return;
63
+ }
64
+
65
+ entry.expiresAt = this._clock.now() + ttlMs;
66
+ }
67
+
68
+ this._state.set(key, entry);
69
+ }
70
+
71
+ public async delete(key: string): Promise<void> {
72
+ this._state.delete(key);
73
+ }
74
+
75
+ public async clear(): Promise<void> {
76
+ this._state.clear();
77
+
78
+ const error = new RateLimitError(
79
+ RateLimitErrorCode.Destroyed,
80
+ undefined,
81
+ 'Store was cleared or destroyed while waiting for lock',
82
+ );
83
+
84
+ for (const queue of this._waitingResolvers.values()) {
85
+ while (queue.size > 0) {
86
+ const waiter = queue.shift();
87
+
88
+ if (waiter) {
89
+ waiter.reject(error);
90
+ }
91
+ }
92
+ }
93
+
94
+ this._waitingResolvers.clear();
95
+ this._activeLocks.clear();
96
+ }
97
+
98
+ public async destroy(): Promise<void> {
99
+ clearInterval(this._cleanupTimer);
100
+ await this.clear();
101
+ }
102
+
103
+ public async acquireLock(key: string): Promise<void> {
104
+ if (this._activeLocks.has(key)) {
105
+ const { promise, resolve, reject } = promiseWithResolvers();
106
+
107
+ const queue = this._waitingResolvers.get(key) ?? new Deque();
108
+ queue.push({ resolve, reject });
109
+ this._waitingResolvers.set(key, queue);
110
+
111
+ await promise;
112
+ } else {
113
+ this._activeLocks.add(key);
114
+ }
115
+ }
116
+
117
+ public async releaseLock(key: string): Promise<void> {
118
+ const queue = this._waitingResolvers.get(key);
119
+
120
+ if (queue && queue.size > 0) {
121
+ const nextWaiter = queue.shift();
122
+
123
+ if (nextWaiter) {
124
+ nextWaiter.resolve();
125
+ }
126
+
127
+ if (queue.size === 0) {
128
+ this._waitingResolvers.delete(key);
129
+ }
130
+ } else {
131
+ this._activeLocks.delete(key);
132
+ this._waitingResolvers.delete(key);
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,286 @@
1
+ import { BinaryHeap } from '@stimulcross/ds-binary-heap';
2
+ import { PolicyPriorityQueue, type Priority, type SelectionPolicy } from '@stimulcross/ds-policy-priority-queue';
3
+ import { type Logger, LogLevel } from '@stimulcross/logger';
4
+ import { ExecutionTickets } from './execution-tickets.js';
5
+ import { Semaphore } from './semaphore.js';
6
+ import { Task } from './task.js';
7
+ import { type Clock } from '../core/clock.js';
8
+ import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
9
+ import { RateLimitError } from '../errors/rate-limit.error.js';
10
+
11
+ /** @internal */
12
+ export interface RateLimiterExecutionOptions {
13
+ id: string;
14
+ key: string;
15
+ expiresAt?: number;
16
+ priority?: number;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ /** @internal */
21
+ export interface RateLimiterExecutorQueueOptions {
22
+ concurrency?: number;
23
+ capacity?: number;
24
+ selectionPolicy?: SelectionPolicy;
25
+ signal?: AbortSignal;
26
+ }
27
+
28
+ /** @internal */
29
+ export class RateLimiterExecutor {
30
+ private readonly _clock: Clock;
31
+ private readonly _tickets = new ExecutionTickets();
32
+ private readonly _semaphore: Semaphore;
33
+ private readonly _queue: PolicyPriorityQueue<Task>;
34
+ private readonly _expiryHeap = new BinaryHeap<Task>((a: Task, b: Task): number => a.expiresAt! - b.expiresAt!);
35
+
36
+ private _drainTimer: ReturnType<typeof setTimeout> | null = null;
37
+ private _expiryTimer: ReturnType<typeof setTimeout> | null = null;
38
+ private _nextExpiryScheduledAt: number | null = null;
39
+
40
+ constructor(
41
+ private readonly _logger: Logger,
42
+ clock: Clock,
43
+ { concurrency, capacity, selectionPolicy }: RateLimiterExecutorQueueOptions = {},
44
+ ) {
45
+ this._clock = clock;
46
+ this._semaphore = new Semaphore(concurrency ?? null);
47
+ this._queue = new PolicyPriorityQueue<Task>({
48
+ capacity,
49
+ selectionPolicy: selectionPolicy as SelectionPolicy<Task> | undefined,
50
+ });
51
+ }
52
+
53
+ public get isQueueFull(): boolean {
54
+ return this._queue.isFull;
55
+ }
56
+
57
+ public get queueSize(): number {
58
+ return this._queue.size;
59
+ }
60
+
61
+ public get queueCapacity(): number {
62
+ return this._queue.capacity;
63
+ }
64
+
65
+ public async execute<T>(fn: () => T | Promise<T>, runAt: number, options: RateLimiterExecutionOptions): Promise<T> {
66
+ const task = new Task<T>(fn, options);
67
+
68
+ task.isCancellable &&
69
+ task.onAbort(() => {
70
+ this._shouldPrintDebug &&
71
+ this._logger.debug(
72
+ `[DROP CANCELLED] [id: ${options.id}, key: ${options.key}] - ${this._getStateDebugString(task.priority)}`,
73
+ );
74
+
75
+ this._tickets.dropLast();
76
+
77
+ const priorityQueue = this._queue.getQueue(task.priority);
78
+ priorityQueue.remove(task);
79
+
80
+ this._drain();
81
+ });
82
+
83
+ this._tickets.add(runAt);
84
+ this._queue.enqueue(task, task.priority);
85
+
86
+ if (task.expiresAt !== undefined) {
87
+ this._expiryHeap.push(task);
88
+ }
89
+
90
+ this._shouldPrintDebug &&
91
+ this._logger.debug(
92
+ `↓ [ENQ] [id: ${options.id}, key: ${options.key}] - ${this._getStateDebugString(task.priority)}`,
93
+ );
94
+
95
+ this._drain();
96
+
97
+ return await task;
98
+ }
99
+
100
+ public clear(): void {
101
+ this._clearDrainTimer();
102
+ this._clearExpiryTimer();
103
+ this._tickets.clear();
104
+ this._expiryHeap.clear();
105
+
106
+ const pendingTasks = this._drainRemainingTasks();
107
+
108
+ if (pendingTasks.length === 0) {
109
+ return;
110
+ }
111
+
112
+ for (const task of pendingTasks) {
113
+ this._shouldPrintDebug &&
114
+ this._logger.debug(
115
+ `[DROP CLEAR] [id: ${task.id}, key: ${task.key}] - Destroy due to clear() - ${this._getStateDebugString(task.priority)}`,
116
+ );
117
+
118
+ task.destroy();
119
+ task.reject(new RateLimitError(RateLimitErrorCode.Destroyed));
120
+ }
121
+ }
122
+
123
+ private get _shouldPrintDebug(): boolean {
124
+ return this._logger.minLevel >= LogLevel.DEBUG;
125
+ }
126
+
127
+ private _drain(): void {
128
+ const now = this._clock.now();
129
+ const expiredTasks = this._extractExpiredTasks(now);
130
+
131
+ for (const task of expiredTasks) {
132
+ this._shouldPrintDebug &&
133
+ this._logger.debug(
134
+ `[DROP EXPIRED] [id: ${task.id}, key: ${task.key}] - ${this._getStateDebugString(task.priority)}`,
135
+ );
136
+
137
+ this._tickets.dropLast();
138
+ task.destroy();
139
+ task.reject(new RateLimitError(RateLimitErrorCode.Expired));
140
+ }
141
+
142
+ this._recalibrateExpiryTimer(now);
143
+
144
+ while (!this._queue.isEmpty) {
145
+ const nextTicketAt = this._tickets.peek();
146
+
147
+ if (nextTicketAt !== undefined && nextTicketAt > now) {
148
+ this._scheduleDrainTimer(nextTicketAt - now);
149
+ return;
150
+ }
151
+
152
+ const isAcquired = this._semaphore.acquire();
153
+
154
+ if (!isAcquired) {
155
+ return;
156
+ }
157
+
158
+ const task = this._queue.dequeue();
159
+
160
+ if (!task) {
161
+ this._semaphore.release();
162
+ return;
163
+ }
164
+
165
+ task.destroy();
166
+ this._tickets.consume();
167
+
168
+ this._shouldPrintDebug &&
169
+ this._logger.debug(
170
+ `↑ [DEQ] [id: ${task.id}, key: ${task.key}] - ${this._getStateDebugString(task.priority)}`,
171
+ );
172
+
173
+ void task.run().finally(() => {
174
+ this._semaphore.release();
175
+ queueMicrotask(() => this._drain());
176
+ });
177
+ }
178
+ }
179
+
180
+ private _getNextExpiryTimestamp(): number | null {
181
+ while (!this._expiryHeap.isEmpty) {
182
+ const task = this._expiryHeap.peek()!;
183
+
184
+ if (!task.isActive) {
185
+ this._expiryHeap.pop();
186
+ continue;
187
+ }
188
+
189
+ return task.expiresAt!;
190
+ }
191
+
192
+ return null;
193
+ }
194
+
195
+ private _extractExpiredTasks(now: number): Task[] {
196
+ const result: Task[] = [];
197
+
198
+ while (!this._expiryHeap.isEmpty) {
199
+ const task = this._expiryHeap.peek()!;
200
+
201
+ if (!task.isActive) {
202
+ this._expiryHeap.pop();
203
+ continue;
204
+ }
205
+
206
+ if (task.expiresAt! > now) {
207
+ break;
208
+ }
209
+
210
+ this._expiryHeap.pop();
211
+
212
+ const priorityQueue = this._queue.getQueue(task.priority);
213
+ priorityQueue.remove(task);
214
+ result.push(task);
215
+ }
216
+
217
+ return result;
218
+ }
219
+
220
+ private _drainRemainingTasks(): Task[] {
221
+ const remaining: Task[] = [];
222
+
223
+ for (const queue of this._queue.queues()) {
224
+ while (!queue.isEmpty) {
225
+ const task = queue.dequeue()!;
226
+ remaining.push(task);
227
+ }
228
+ }
229
+
230
+ return remaining;
231
+ }
232
+
233
+ private _scheduleDrainTimer(delayMs: number): void {
234
+ if (this._drainTimer) {
235
+ return;
236
+ }
237
+
238
+ this._drainTimer = setTimeout(() => {
239
+ this._clearDrainTimer();
240
+ this._drain();
241
+ }, delayMs);
242
+ }
243
+
244
+ private _recalibrateExpiryTimer(now: number): void {
245
+ const nextExpiry = this._getNextExpiryTimestamp();
246
+
247
+ if (nextExpiry === null) {
248
+ this._clearExpiryTimer();
249
+ this._nextExpiryScheduledAt = null;
250
+
251
+ return;
252
+ }
253
+
254
+ if (this._nextExpiryScheduledAt === nextExpiry) {
255
+ return;
256
+ }
257
+
258
+ this._clearExpiryTimer();
259
+
260
+ const delay = Math.max(0, nextExpiry - now);
261
+
262
+ this._nextExpiryScheduledAt = nextExpiry;
263
+ this._expiryTimer = setTimeout(() => {
264
+ this._clearExpiryTimer();
265
+ this._drain();
266
+ }, delay);
267
+ }
268
+
269
+ private _clearDrainTimer(): void {
270
+ if (this._drainTimer) {
271
+ clearTimeout(this._drainTimer);
272
+ this._drainTimer = null;
273
+ }
274
+ }
275
+
276
+ private _clearExpiryTimer(): void {
277
+ if (this._expiryTimer) {
278
+ clearTimeout(this._expiryTimer);
279
+ this._expiryTimer = null;
280
+ }
281
+ }
282
+
283
+ private _getStateDebugString(priority: Priority): string {
284
+ return `prt: ${priority} | q: ${this._queue.size}/${this._queue.capacity}`;
285
+ }
286
+ }
@@ -0,0 +1,31 @@
1
+ /** @internal */
2
+ export class Semaphore {
3
+ private _permits: number;
4
+
5
+ constructor(private readonly _maxPermits: number | null) {
6
+ if (_maxPermits !== null && (!Number.isSafeInteger(_maxPermits) || _maxPermits <= 0)) {
7
+ throw new Error('Maximum permits must be a non-negative integer or null');
8
+ }
9
+
10
+ this._permits = _maxPermits ?? 0;
11
+ }
12
+
13
+ public acquire(): boolean {
14
+ if (this._maxPermits === null) {
15
+ return true;
16
+ }
17
+
18
+ if (this._permits > 0) {
19
+ this._permits--;
20
+ return true;
21
+ }
22
+
23
+ return false;
24
+ }
25
+
26
+ public release(): void {
27
+ if (this._maxPermits !== null && this._permits < this._maxPermits) {
28
+ this._permits++;
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,141 @@
1
+ import { Priority } from '@stimulcross/ds-policy-priority-queue';
2
+ import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
3
+ import { RateLimitError } from '../errors/rate-limit.error.js';
4
+ import { promiseWithResolvers } from '../utils/promise-with-resolvers.js';
5
+
6
+ /** @internal */
7
+ export interface TaskOptions {
8
+ id: string;
9
+ key: string;
10
+ priority?: Priority;
11
+ expiresAt?: number;
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ /** @internal */
16
+ export class Task<T = any> implements PromiseLike<T> {
17
+ private readonly _task: () => T | Promise<T>;
18
+ private readonly _promise: Promise<T>;
19
+ private readonly _resolve: (v: T) => void;
20
+ private readonly _reject: (reason?: any) => void;
21
+
22
+ private readonly _id: string;
23
+ private readonly _key: string;
24
+ private readonly _priority: Priority;
25
+ private readonly _expiresAt?: number;
26
+
27
+ private _signal?: AbortSignal;
28
+ private _abortListener?: () => void;
29
+ private _abortHandler?: () => void;
30
+
31
+ private _isActive = true;
32
+ private _isAborted = false;
33
+
34
+ constructor(task: () => T | Promise<T>, { id, key, priority = Priority.Normal, expiresAt, signal }: TaskOptions) {
35
+ const { promise, resolve, reject } = promiseWithResolvers<T>();
36
+
37
+ this._task = task;
38
+ this._promise = promise;
39
+ this._resolve = resolve;
40
+ this._reject = reject;
41
+ this._id = id;
42
+ this._key = key;
43
+ this._priority = priority;
44
+ this._expiresAt = expiresAt;
45
+
46
+ if (signal) {
47
+ this._signal = signal;
48
+
49
+ this._abortListener = (): void => {
50
+ this._isActive = false;
51
+ this._isAborted = true;
52
+
53
+ this._abortHandler?.();
54
+ this._reject(new RateLimitError(RateLimitErrorCode.Cancelled, undefined, 'Aborted by client'));
55
+ this.destroy();
56
+ };
57
+
58
+ this._signal.addEventListener('abort', this._abortListener, { once: true });
59
+ }
60
+ }
61
+
62
+ public get id(): string {
63
+ return this._id;
64
+ }
65
+
66
+ public get key(): string {
67
+ return this._key;
68
+ }
69
+
70
+ public get priority(): Priority {
71
+ return this._priority;
72
+ }
73
+
74
+ public get expiresAt(): number | undefined {
75
+ return this._expiresAt;
76
+ }
77
+
78
+ public get isActive(): boolean {
79
+ return this._isActive;
80
+ }
81
+
82
+ public get isCancellable(): boolean {
83
+ return Boolean(this._signal);
84
+ }
85
+
86
+ public get isAborted(): boolean {
87
+ return this._isAborted;
88
+ }
89
+
90
+ public async run(): Promise<void> {
91
+ this._isActive = false;
92
+
93
+ try {
94
+ const result = await this._task();
95
+ this._resolve(result);
96
+ } catch (e) {
97
+ this._reject(e);
98
+ }
99
+ }
100
+
101
+ public reject(reason: unknown): void {
102
+ this._isActive = false;
103
+ this._reject(reason);
104
+ }
105
+
106
+ public destroy(): void {
107
+ this._isActive = false;
108
+
109
+ if (this._signal && this._abortListener) {
110
+ this._signal.removeEventListener('abort', this._abortListener);
111
+
112
+ this._signal = undefined;
113
+ this._abortListener = undefined;
114
+ }
115
+
116
+ if (this._abortHandler) {
117
+ this._abortHandler = undefined;
118
+ }
119
+ }
120
+
121
+ public onAbort(handler: () => void): void {
122
+ this._abortHandler = handler;
123
+ }
124
+
125
+ public then<TResult1 = T, TResult2 = never>(
126
+ onfulfilled?: ((value: T) => PromiseLike<TResult1> | TResult1) | null,
127
+ onrejected?: ((reason: any) => PromiseLike<TResult2> | TResult2) | null,
128
+ ): PromiseLike<TResult1 | TResult2> {
129
+ return this._promise.then(onfulfilled, onrejected);
130
+ }
131
+
132
+ public catch<TResult = never>(
133
+ onrejected?: ((reason: any) => PromiseLike<TResult> | TResult) | null,
134
+ ): PromiseLike<T | TResult> {
135
+ return this._promise.catch(onrejected);
136
+ }
137
+
138
+ public finally(onfinally?: (() => void) | null): PromiseLike<T> {
139
+ return this._promise.finally(onfinally);
140
+ }
141
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Defines the behavior when the limit is reached.
3
+ *
4
+ * Available options:
5
+ * - `reject` - rejects the task with `LIMIT_EXCEEDED` error code
6
+ * - `enqueue` - enqueues the task
7
+ */
8
+ export type LimitBehavior = 'enqueue' | 'reject';
@@ -0,0 +1,16 @@
1
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
2
+
3
+ /** @internal */
4
+ export function generateRandomString(length: number = 7): string {
5
+ if (!Number.isSafeInteger(length) || length < 0) {
6
+ throw new RangeError(`Invalid length: ${length}. Length must be a positive integer.`);
7
+ }
8
+
9
+ const result = new Array(length);
10
+
11
+ for (let i = 0; i < length; i++) {
12
+ result.push(characters.charAt(Math.floor(Math.random() * characters.length)));
13
+ }
14
+
15
+ return result.join('');
16
+ }
@@ -0,0 +1,23 @@
1
+ /** @internal */
2
+ export interface PromiseWithResolvers<T = void> {
3
+ promise: Promise<T>;
4
+ resolve: (value: T | PromiseLike<T>) => void;
5
+ reject: (reason?: unknown) => void;
6
+ }
7
+
8
+ /** @internal */
9
+ export function promiseWithResolvers<T = void>(): PromiseWithResolvers<T> {
10
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
11
+ if (Promise.withResolvers) {
12
+ return Promise.withResolvers<T>();
13
+ }
14
+
15
+ let resolve!: (value: T | PromiseLike<T>) => void;
16
+ let reject!: (reason: unknown) => void;
17
+ const promise = new Promise<T>((_resolve, _reject) => {
18
+ resolve = _resolve;
19
+ reject = _reject;
20
+ });
21
+
22
+ return { promise, resolve, reject };
23
+ }
@@ -0,0 +1,4 @@
1
+ /** @internal */
2
+ export function sanitizeError(error: unknown): Error {
3
+ return error instanceof Error ? error : new Error('Non-error thrown. Check "cause" property', { cause: error });
4
+ }
@@ -0,0 +1,22 @@
1
+ import { Priority } from '@stimulcross/ds-policy-priority-queue';
2
+
3
+ /** @internal */
4
+ export function sanitizePriority(priority: number): Priority {
5
+ if (!Number.isFinite(priority)) {
6
+ return Priority.Normal;
7
+ }
8
+
9
+ if (priority < Priority.Lowest) {
10
+ return Priority.Lowest;
11
+ }
12
+
13
+ if (priority > Priority.Highest) {
14
+ return Priority.Highest;
15
+ }
16
+
17
+ if (!Number.isInteger(priority)) {
18
+ priority = Math.round(priority);
19
+ }
20
+
21
+ return priority;
22
+ }
@@ -0,0 +1,16 @@
1
+ import { InvalidCostError } from '../errors/invalid-cost.error.js';
2
+
3
+ /** @internal */
4
+ export function validateCost(cost: number, max?: number, min?: number): void {
5
+ if (!Number.isSafeInteger(cost) || cost < 0) {
6
+ throw new InvalidCostError(`Invalid cost: ${cost}. Cost must be a positive integer.`, cost);
7
+ }
8
+
9
+ if (max !== undefined && cost > max) {
10
+ throw new InvalidCostError(`Invalid cost: ${cost}. Cost must be greater than or equal to ${max}.`, cost);
11
+ }
12
+
13
+ if (min !== undefined && cost < min) {
14
+ throw new InvalidCostError(`Invalid cost: ${cost}. Cost must be greater than or equal to ${min}.`, cost);
15
+ }
16
+ }