@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { jobDuration, jobQueueDepth, jobsProcessedTotal } from "../metrics.js";
|
|
4
|
+
import {
|
|
5
|
+
addJob,
|
|
6
|
+
checkQueueHealth,
|
|
7
|
+
createQueue,
|
|
8
|
+
createWorker,
|
|
9
|
+
getRegisteredQueues,
|
|
10
|
+
updateQueueDepths,
|
|
11
|
+
} from "./index.js";
|
|
12
|
+
|
|
13
|
+
// ─── getRegisteredQueues ──────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("getRegisteredQueues", () => {
|
|
16
|
+
let q1;
|
|
17
|
+
let q2;
|
|
18
|
+
|
|
19
|
+
after(async () => {
|
|
20
|
+
await q1?.close().catch(() => {});
|
|
21
|
+
await q2?.close().catch(() => {});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns the internal queues Map", () => {
|
|
25
|
+
const result = getRegisteredQueues();
|
|
26
|
+
assert.ok(result instanceof Map, "should be a Map");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("reflects queues registered via createQueue", () => {
|
|
30
|
+
const before = getRegisteredQueues().size;
|
|
31
|
+
q1 = createQueue("test-registry-queue");
|
|
32
|
+
assert.strictEqual(getRegisteredQueues().size, before + 1);
|
|
33
|
+
assert.ok(getRegisteredQueues().has("test-registry-queue"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("createQueue is idempotent - same instance on repeated calls", () => {
|
|
37
|
+
q2 = createQueue("test-idempotent-queue");
|
|
38
|
+
const again = createQueue("test-idempotent-queue");
|
|
39
|
+
assert.strictEqual(q2, again, "should return the same queue instance");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── createQueue close safety ────────────────────────────────────────────────
|
|
44
|
+
// Regression: a closed queue used to stay in the singleton registry, so every
|
|
45
|
+
// later createQueue(name) handed back the same poisoned, unusable instance.
|
|
46
|
+
// Required behavior: close → evict → recreate → usable.
|
|
47
|
+
|
|
48
|
+
describe("createQueue - close safety", () => {
|
|
49
|
+
const NAME = "test-close-recreate-queue";
|
|
50
|
+
let queue;
|
|
51
|
+
|
|
52
|
+
after(async () => {
|
|
53
|
+
await queue?.close().catch(() => {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("closing a queue evicts it from the registry", async () => {
|
|
57
|
+
queue = createQueue(NAME);
|
|
58
|
+
assert.ok(
|
|
59
|
+
getRegisteredQueues().has(NAME),
|
|
60
|
+
"queue should be registered while open",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await queue.close();
|
|
64
|
+
|
|
65
|
+
assert.ok(
|
|
66
|
+
!getRegisteredQueues().has(NAME),
|
|
67
|
+
"closed queue should be evicted from the registry",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("createQueue after close returns a fresh, usable queue", async () => {
|
|
72
|
+
const first = queue; // the closed instance from the previous test
|
|
73
|
+
|
|
74
|
+
queue = createQueue(NAME);
|
|
75
|
+
|
|
76
|
+
assert.notStrictEqual(
|
|
77
|
+
queue,
|
|
78
|
+
first,
|
|
79
|
+
"should build a new instance instead of returning the closed one",
|
|
80
|
+
);
|
|
81
|
+
assert.ok(
|
|
82
|
+
getRegisteredQueues().has(NAME),
|
|
83
|
+
"new instance should be registered",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// "Usable" means it can actually reach Redis and enqueue a job.
|
|
87
|
+
// Use the public readiness primitive - `client` is not stable across
|
|
88
|
+
// BullMQ majors and was removed in v6.
|
|
89
|
+
await queue.waitUntilReady();
|
|
90
|
+
const job = await queue.add("close-recreate-probe", { probe: true });
|
|
91
|
+
assert.ok(job.id, "fresh queue should enqueue a job");
|
|
92
|
+
|
|
93
|
+
await queue.obliterate({ force: true }).catch(() => {});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a queue closed outside the wrapper is still replaced", async () => {
|
|
97
|
+
const stale = queue;
|
|
98
|
+
// Bypass our evict-on-close wrapper and close via the prototype directly
|
|
99
|
+
await Object.getPrototypeOf(stale).close.call(stale);
|
|
100
|
+
|
|
101
|
+
queue = createQueue(NAME);
|
|
102
|
+
|
|
103
|
+
assert.notStrictEqual(
|
|
104
|
+
queue,
|
|
105
|
+
stale,
|
|
106
|
+
"a known-closed instance must never be handed out again",
|
|
107
|
+
);
|
|
108
|
+
assert.ok(getRegisteredQueues().has(NAME));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── addJob deduplication ─────────────────────────────────────────────────────
|
|
113
|
+
// Regression: dedup was hand-rolled with a raw `SET NX` against `queue.client`.
|
|
114
|
+
// BullMQ 6 removed that getter, so the `SET NX` threw, the best-effort `catch`
|
|
115
|
+
// swallowed it, and every "deduplicated" job was enqueued anyway - silently.
|
|
116
|
+
// These tests pin that dedup works through the public BullMQ option and that
|
|
117
|
+
// we never reach for BullMQ internals to do it.
|
|
118
|
+
|
|
119
|
+
describe("addJob - deduplication", () => {
|
|
120
|
+
const NAME = "test-dedup-queue";
|
|
121
|
+
let queue;
|
|
122
|
+
|
|
123
|
+
before(() => {
|
|
124
|
+
queue = createQueue(NAME);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
after(async () => {
|
|
128
|
+
await queue?.obliterate({ force: true }).catch(() => {});
|
|
129
|
+
await queue?.close().catch(() => {});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a repeated dedupKey does not enqueue a second job", async () => {
|
|
133
|
+
const first = await addJob(
|
|
134
|
+
queue,
|
|
135
|
+
"dedup-probe",
|
|
136
|
+
{ n: 1 },
|
|
137
|
+
{
|
|
138
|
+
dedupKey: "probe-key",
|
|
139
|
+
dedupTTL: 60,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
assert.ok(first?.id, "the first add should enqueue");
|
|
143
|
+
|
|
144
|
+
// BullMQ throttles the duplicate instead of creating a second job.
|
|
145
|
+
const second = await addJob(
|
|
146
|
+
queue,
|
|
147
|
+
"dedup-probe",
|
|
148
|
+
{ n: 2 },
|
|
149
|
+
{
|
|
150
|
+
dedupKey: "probe-key",
|
|
151
|
+
dedupTTL: 60,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
assert.strictEqual(
|
|
155
|
+
second?.id,
|
|
156
|
+
first.id,
|
|
157
|
+
"a duplicate dedupKey must resolve to the already-queued job",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const waiting = await queue.getWaitingCount();
|
|
161
|
+
assert.strictEqual(waiting, 1, "only one job should be waiting");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("different dedupKeys enqueue independently", async () => {
|
|
165
|
+
const a = await addJob(
|
|
166
|
+
queue,
|
|
167
|
+
"dedup-probe-b",
|
|
168
|
+
{ n: 1 },
|
|
169
|
+
{
|
|
170
|
+
dedupKey: "probe-a",
|
|
171
|
+
dedupTTL: 60,
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
const b = await addJob(
|
|
175
|
+
queue,
|
|
176
|
+
"dedup-probe-b",
|
|
177
|
+
{ n: 1 },
|
|
178
|
+
{
|
|
179
|
+
dedupKey: "probe-b",
|
|
180
|
+
dedupTTL: 60,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
assert.notStrictEqual(
|
|
184
|
+
a.id,
|
|
185
|
+
b.id,
|
|
186
|
+
"distinct keys must produce distinct jobs",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("no dedupKey means no deduplication", async () => {
|
|
191
|
+
const a = await addJob(queue, "dedup-probe-none", { n: 1 });
|
|
192
|
+
const b = await addJob(queue, "dedup-probe-none", { n: 1 });
|
|
193
|
+
assert.notStrictEqual(a.id, b.id, "jobs without a dedupKey always enqueue");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ─── checkQueueHealth ─────────────────────────────────────────────────────────
|
|
198
|
+
// Uses the public `waitUntilReady()`; a healthy Redis must report healthy, not
|
|
199
|
+
// "unavailable" (which is what happened once BullMQ removed `queue.client`).
|
|
200
|
+
|
|
201
|
+
describe("checkQueueHealth", () => {
|
|
202
|
+
test("reports healthy against a live Redis", async () => {
|
|
203
|
+
assert.strictEqual(await checkQueueHealth(), true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ─── updateQueueDepths ────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe("updateQueueDepths", () => {
|
|
210
|
+
test("resolves without error when no queues are registered", async () => {
|
|
211
|
+
// Create a fresh scenario - if no queues in the singleton map, just skip
|
|
212
|
+
// We can't easily reset the singleton, so we just verify it doesn't throw
|
|
213
|
+
await assert.doesNotReject(() => updateQueueDepths());
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ─── createWorker auto-instrumentation ───────────────────────────────────────
|
|
218
|
+
// These tests require a live Redis connection.
|
|
219
|
+
|
|
220
|
+
describe("createWorker - metrics instrumentation", () => {
|
|
221
|
+
const TEST_QUEUE = "test-metrics-queue";
|
|
222
|
+
let queue;
|
|
223
|
+
|
|
224
|
+
before(async () => {
|
|
225
|
+
queue = createQueue(TEST_QUEUE);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
after(async () => {
|
|
229
|
+
await queue?.close().catch(() => {});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("jobsProcessedTotal increments on job completion", async () => {
|
|
233
|
+
const before =
|
|
234
|
+
jobsProcessedTotal.values.get(
|
|
235
|
+
`{queue="${TEST_QUEUE}",status="completed"}`,
|
|
236
|
+
) ?? 0;
|
|
237
|
+
|
|
238
|
+
const worker = createWorker(TEST_QUEUE, async () => ({ ok: true }), {
|
|
239
|
+
concurrency: 1,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// Add a job and wait for it to complete
|
|
244
|
+
await new Promise((resolve, reject) => {
|
|
245
|
+
const timeout = setTimeout(
|
|
246
|
+
() => reject(new Error("Job did not complete in time")),
|
|
247
|
+
5000,
|
|
248
|
+
);
|
|
249
|
+
worker.once("completed", () => {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
resolve();
|
|
252
|
+
});
|
|
253
|
+
worker.once("error", (err) => {
|
|
254
|
+
clearTimeout(timeout);
|
|
255
|
+
reject(err);
|
|
256
|
+
});
|
|
257
|
+
queue.add("test-job", { test: true }).catch(reject);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const after =
|
|
261
|
+
jobsProcessedTotal.values.get(
|
|
262
|
+
`{queue="${TEST_QUEUE}",status="completed"}`,
|
|
263
|
+
) ?? 0;
|
|
264
|
+
assert.ok(after > before, "completed counter should have incremented");
|
|
265
|
+
} finally {
|
|
266
|
+
await worker.close().catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("jobsProcessedTotal increments on job failure", async () => {
|
|
271
|
+
const before =
|
|
272
|
+
jobsProcessedTotal.values.get(
|
|
273
|
+
`{queue="${TEST_QUEUE}",status="failed"}`,
|
|
274
|
+
) ?? 0;
|
|
275
|
+
|
|
276
|
+
const worker = createWorker(
|
|
277
|
+
TEST_QUEUE,
|
|
278
|
+
async () => {
|
|
279
|
+
throw new Error("intentional failure");
|
|
280
|
+
},
|
|
281
|
+
{ concurrency: 1 },
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await new Promise((resolve, reject) => {
|
|
286
|
+
const timeout = setTimeout(
|
|
287
|
+
() => reject(new Error("Job did not fail in time")),
|
|
288
|
+
5000,
|
|
289
|
+
);
|
|
290
|
+
worker.once("failed", () => {
|
|
291
|
+
clearTimeout(timeout);
|
|
292
|
+
resolve();
|
|
293
|
+
});
|
|
294
|
+
worker.once("error", (err) => {
|
|
295
|
+
clearTimeout(timeout);
|
|
296
|
+
reject(err);
|
|
297
|
+
});
|
|
298
|
+
// attempts:1 so it fails immediately without retries
|
|
299
|
+
queue
|
|
300
|
+
.add("test-fail-job", { test: true }, { attempts: 1 })
|
|
301
|
+
.catch(reject);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const after =
|
|
305
|
+
jobsProcessedTotal.values.get(
|
|
306
|
+
`{queue="${TEST_QUEUE}",status="failed"}`,
|
|
307
|
+
) ?? 0;
|
|
308
|
+
assert.ok(after > before, "failed counter should have incremented");
|
|
309
|
+
} finally {
|
|
310
|
+
await worker.close().catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("jobDuration records a histogram observation on completion", async () => {
|
|
315
|
+
const beforeCount =
|
|
316
|
+
jobDuration.values.get(`{name="timed-job",queue="${TEST_QUEUE}"}`)
|
|
317
|
+
?.count ?? 0;
|
|
318
|
+
|
|
319
|
+
const worker = createWorker(TEST_QUEUE, async () => ({ ok: true }), {
|
|
320
|
+
concurrency: 1,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await new Promise((resolve, reject) => {
|
|
325
|
+
const timeout = setTimeout(
|
|
326
|
+
() => reject(new Error("Timed job did not complete")),
|
|
327
|
+
5000,
|
|
328
|
+
);
|
|
329
|
+
worker.once("completed", () => {
|
|
330
|
+
clearTimeout(timeout);
|
|
331
|
+
resolve();
|
|
332
|
+
});
|
|
333
|
+
worker.once("error", (err) => {
|
|
334
|
+
clearTimeout(timeout);
|
|
335
|
+
reject(err);
|
|
336
|
+
});
|
|
337
|
+
queue.add("timed-job", { test: true }).catch(reject);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const afterCount =
|
|
341
|
+
jobDuration.values.get(`{name="timed-job",queue="${TEST_QUEUE}"}`)
|
|
342
|
+
?.count ?? 0;
|
|
343
|
+
assert.ok(afterCount > beforeCount, "histogram count should have grown");
|
|
344
|
+
} finally {
|
|
345
|
+
await worker.close().catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("updateQueueDepths sets job_queue_depth gauge", async () => {
|
|
350
|
+
// Add a few jobs to create a backlog, then update depths
|
|
351
|
+
await queue.addBulk([
|
|
352
|
+
{ name: "depth-test", data: {} },
|
|
353
|
+
{ name: "depth-test", data: {} },
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
await updateQueueDepths();
|
|
357
|
+
|
|
358
|
+
const depth = jobQueueDepth.values.get(`{queue="${TEST_QUEUE}"}`) ?? 0;
|
|
359
|
+
// We added 2 jobs; depth should be >= 2 (could be higher if prior test jobs remain)
|
|
360
|
+
assert.ok(depth >= 2, `expected depth >= 2, got ${depth}`);
|
|
361
|
+
|
|
362
|
+
// Drain queue so it doesn't interfere with other tests
|
|
363
|
+
await queue.obliterate({ force: true }).catch(() => {});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Rate Limiting Module
|
|
3
|
+
* Provides both in-memory and Redis-based rate limiting
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory rate limiter (for single-instance deployments)
|
|
8
|
+
*/
|
|
9
|
+
class MemoryRateLimiter {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.store = new Map();
|
|
12
|
+
this.cleanupInterval = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start periodic cleanup of expired records
|
|
17
|
+
*/
|
|
18
|
+
startCleanup(intervalMs = 60000) {
|
|
19
|
+
if (this.cleanupInterval) return;
|
|
20
|
+
|
|
21
|
+
this.cleanupInterval = setInterval(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [key, record] of this.store.entries()) {
|
|
24
|
+
if (now > record.resetTime) {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}, intervalMs);
|
|
29
|
+
|
|
30
|
+
// Don't keep the process alive just for cleanup
|
|
31
|
+
this.cleanupInterval.unref();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stop cleanup interval
|
|
36
|
+
*/
|
|
37
|
+
stopCleanup() {
|
|
38
|
+
if (this.cleanupInterval) {
|
|
39
|
+
clearInterval(this.cleanupInterval);
|
|
40
|
+
this.cleanupInterval = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check and increment rate limit
|
|
46
|
+
*/
|
|
47
|
+
async checkLimit(key, maxRequests, windowMs) {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const record = this.store.get(key) || {
|
|
50
|
+
count: 0,
|
|
51
|
+
resetTime: now + windowMs,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Reset if window has passed
|
|
55
|
+
if (now > record.resetTime) {
|
|
56
|
+
record.count = 0;
|
|
57
|
+
record.resetTime = now + windowMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check if limit exceeded
|
|
61
|
+
if (record.count >= maxRequests) {
|
|
62
|
+
return {
|
|
63
|
+
limited: true,
|
|
64
|
+
remaining: 0,
|
|
65
|
+
resetTime: record.resetTime,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Increment counter
|
|
70
|
+
record.count++;
|
|
71
|
+
this.store.set(key, record);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
limited: false,
|
|
75
|
+
remaining: maxRequests - record.count,
|
|
76
|
+
resetTime: record.resetTime,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reset a specific key
|
|
82
|
+
*/
|
|
83
|
+
async reset(key) {
|
|
84
|
+
this.store.delete(key);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Clear all records
|
|
89
|
+
*/
|
|
90
|
+
async clear() {
|
|
91
|
+
this.store.clear();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Redis-based rate limiter (for multi-instance deployments)
|
|
97
|
+
* Uses Lua scripting for atomic check-and-increment.
|
|
98
|
+
*/
|
|
99
|
+
class RedisRateLimiter {
|
|
100
|
+
constructor(redisClient, options = {}) {
|
|
101
|
+
this.redis = redisClient;
|
|
102
|
+
this.failOpen = options.failOpen ?? true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check and increment rate limit using an atomic Lua script.
|
|
107
|
+
* Prevents TOCTOU race conditions by doing INCR + PEXPIRE in one round-trip.
|
|
108
|
+
*/
|
|
109
|
+
async checkLimit(key, maxRequests, windowMs) {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const windowKey = `ratelimit:${key}`;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Atomic Lua: INCR the key, set PEXPIRE on first request, return [count, pttl]
|
|
115
|
+
const luaScript = `
|
|
116
|
+
local count = redis.call('INCR', KEYS[1])
|
|
117
|
+
if count == 1 then
|
|
118
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
119
|
+
end
|
|
120
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
121
|
+
return {count, ttl}
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
const [currentCount, ttl] = await this.redis.eval(
|
|
125
|
+
luaScript,
|
|
126
|
+
1,
|
|
127
|
+
windowKey,
|
|
128
|
+
windowMs,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const resetTime = ttl > 0 ? now + ttl : now + windowMs;
|
|
132
|
+
|
|
133
|
+
if (currentCount > maxRequests) {
|
|
134
|
+
return {
|
|
135
|
+
limited: true,
|
|
136
|
+
remaining: 0,
|
|
137
|
+
resetTime,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
limited: false,
|
|
143
|
+
remaining: maxRequests - currentCount,
|
|
144
|
+
resetTime,
|
|
145
|
+
};
|
|
146
|
+
} catch (_error) {
|
|
147
|
+
// Configurable fail-open / fail-closed behaviour
|
|
148
|
+
if (this.failOpen) {
|
|
149
|
+
return {
|
|
150
|
+
limited: false,
|
|
151
|
+
remaining: maxRequests,
|
|
152
|
+
resetTime: now + windowMs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
limited: true,
|
|
157
|
+
remaining: 0,
|
|
158
|
+
resetTime: now + windowMs,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reset a specific key
|
|
165
|
+
*/
|
|
166
|
+
async reset(key) {
|
|
167
|
+
await this.redis.del(`ratelimit:${key}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear all rate limit keys using SCAN (non-blocking, production-safe)
|
|
172
|
+
*/
|
|
173
|
+
async clear() {
|
|
174
|
+
let cursor = "0";
|
|
175
|
+
do {
|
|
176
|
+
const [nextCursor, keys] = await this.redis.scan(
|
|
177
|
+
cursor,
|
|
178
|
+
"MATCH",
|
|
179
|
+
"ratelimit:*",
|
|
180
|
+
"COUNT",
|
|
181
|
+
100,
|
|
182
|
+
);
|
|
183
|
+
cursor = nextCursor;
|
|
184
|
+
if (keys.length > 0) {
|
|
185
|
+
await this.redis.del(...keys);
|
|
186
|
+
}
|
|
187
|
+
} while (cursor !== "0");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a rate limiter instance
|
|
193
|
+
* @param {Object} options - Configuration options
|
|
194
|
+
* @param {string} options.type - "memory" or "redis"
|
|
195
|
+
* @param {Object} options.redisClient - Redis client instance (required if type is "redis")
|
|
196
|
+
* @returns {MemoryRateLimiter|RedisRateLimiter}
|
|
197
|
+
*/
|
|
198
|
+
export function createRateLimiter(options = {}) {
|
|
199
|
+
const { type = "memory", redisClient = null } = options;
|
|
200
|
+
|
|
201
|
+
if (type === "redis") {
|
|
202
|
+
if (!redisClient) {
|
|
203
|
+
throw new Error("Redis client is required for Redis-based rate limiting");
|
|
204
|
+
}
|
|
205
|
+
return new RedisRateLimiter(redisClient, { failOpen: options.failOpen });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const limiter = new MemoryRateLimiter();
|
|
209
|
+
limiter.startCleanup();
|
|
210
|
+
return limiter;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Rate limiter configuration
|
|
215
|
+
*/
|
|
216
|
+
export const RATE_LIMIT_PRESETS = {
|
|
217
|
+
strict: {
|
|
218
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
219
|
+
maxRequests: 5,
|
|
220
|
+
},
|
|
221
|
+
moderate: {
|
|
222
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
223
|
+
maxRequests: 50,
|
|
224
|
+
},
|
|
225
|
+
relaxed: {
|
|
226
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
227
|
+
maxRequests: 100,
|
|
228
|
+
},
|
|
229
|
+
auth: {
|
|
230
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
231
|
+
maxRequests: 5,
|
|
232
|
+
},
|
|
233
|
+
api: {
|
|
234
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
235
|
+
maxRequests: 100,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Helper function to create rate limit middleware
|
|
241
|
+
* @param {Object} limiter - Rate limiter instance
|
|
242
|
+
* @param {Object} config - Rate limit configuration
|
|
243
|
+
* @returns {Function} Middleware function
|
|
244
|
+
*/
|
|
245
|
+
export function createRateLimitMiddleware(
|
|
246
|
+
limiter,
|
|
247
|
+
config = RATE_LIMIT_PRESETS.api,
|
|
248
|
+
) {
|
|
249
|
+
return async (ip, path) => {
|
|
250
|
+
const key = `${ip}:${path}`;
|
|
251
|
+
return limiter.checkLimit(key, config.maxRequests, config.windowMs);
|
|
252
|
+
};
|
|
253
|
+
}
|