limitly 1.0.2 → 3.0.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.
Files changed (118) hide show
  1. package/README.md +471 -22
  2. package/dist/algorithms/concurrency.d.ts +11 -0
  3. package/dist/algorithms/concurrency.d.ts.map +1 -0
  4. package/dist/algorithms/concurrency.js +19 -0
  5. package/dist/algorithms/concurrency.js.map +1 -0
  6. package/dist/algorithms/factory.d.ts +3 -2
  7. package/dist/algorithms/factory.d.ts.map +1 -1
  8. package/dist/algorithms/factory.js +9 -3
  9. package/dist/algorithms/factory.js.map +1 -1
  10. package/dist/algorithms/gcra.d.ts +10 -0
  11. package/dist/algorithms/gcra.d.ts.map +1 -0
  12. package/dist/algorithms/gcra.js +15 -0
  13. package/dist/algorithms/gcra.js.map +1 -0
  14. package/dist/algorithms/sliding-window.d.ts +4 -4
  15. package/dist/algorithms/sliding-window.d.ts.map +1 -1
  16. package/dist/algorithms/sliding-window.js +3 -23
  17. package/dist/algorithms/sliding-window.js.map +1 -1
  18. package/dist/algorithms/token-bucket.d.ts +4 -4
  19. package/dist/algorithms/token-bucket.d.ts.map +1 -1
  20. package/dist/algorithms/token-bucket.js +3 -20
  21. package/dist/algorithms/token-bucket.js.map +1 -1
  22. package/dist/index.d.ts +19 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +46 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/limiter.d.ts +23 -7
  27. package/dist/limiter.d.ts.map +1 -1
  28. package/dist/limiter.js +86 -16
  29. package/dist/limiter.js.map +1 -1
  30. package/dist/middleware/bun.d.ts +9 -0
  31. package/dist/middleware/bun.d.ts.map +1 -0
  32. package/dist/middleware/bun.js +107 -0
  33. package/dist/middleware/bun.js.map +1 -0
  34. package/dist/middleware/express.d.ts.map +1 -1
  35. package/dist/middleware/express.js +30 -14
  36. package/dist/middleware/express.js.map +1 -1
  37. package/dist/middleware/fastify.d.ts +3 -3
  38. package/dist/middleware/fastify.d.ts.map +1 -1
  39. package/dist/middleware/fastify.js +34 -17
  40. package/dist/middleware/fastify.js.map +1 -1
  41. package/dist/middleware/hono.d.ts +5 -0
  42. package/dist/middleware/hono.d.ts.map +1 -0
  43. package/dist/middleware/hono.js +71 -0
  44. package/dist/middleware/hono.js.map +1 -0
  45. package/dist/middleware/koa.d.ts +5 -0
  46. package/dist/middleware/koa.d.ts.map +1 -0
  47. package/dist/middleware/koa.js +65 -0
  48. package/dist/middleware/koa.js.map +1 -0
  49. package/dist/middleware/nest.d.ts +14 -0
  50. package/dist/middleware/nest.d.ts.map +1 -0
  51. package/dist/middleware/nest.js +112 -0
  52. package/dist/middleware/nest.js.map +1 -0
  53. package/dist/observability/index.d.ts +4 -0
  54. package/dist/observability/index.d.ts.map +1 -0
  55. package/dist/observability/index.js +10 -0
  56. package/dist/observability/index.js.map +1 -0
  57. package/dist/observability/opentelemetry.d.ts +28 -0
  58. package/dist/observability/opentelemetry.d.ts.map +1 -0
  59. package/dist/observability/opentelemetry.js +85 -0
  60. package/dist/observability/opentelemetry.js.map +1 -0
  61. package/dist/observability/prometheus.d.ts +27 -0
  62. package/dist/observability/prometheus.d.ts.map +1 -0
  63. package/dist/observability/prometheus.js +88 -0
  64. package/dist/observability/prometheus.js.map +1 -0
  65. package/dist/observability/types.d.ts +9 -0
  66. package/dist/observability/types.d.ts.map +1 -0
  67. package/dist/observability/types.js +3 -0
  68. package/dist/observability/types.js.map +1 -0
  69. package/dist/prometheus.d.ts +3 -0
  70. package/dist/prometheus.d.ts.map +1 -0
  71. package/dist/prometheus.js +9 -0
  72. package/dist/prometheus.js.map +1 -0
  73. package/dist/scripts/concurrencyAcquire.lua +34 -0
  74. package/dist/scripts/concurrencyRelease.lua +9 -0
  75. package/dist/scripts/gcra.lua +38 -0
  76. package/dist/stores/factory.d.ts +5 -0
  77. package/dist/stores/factory.d.ts.map +1 -0
  78. package/dist/stores/factory.js +40 -0
  79. package/dist/stores/factory.js.map +1 -0
  80. package/dist/stores/memcached-store.d.ts +16 -0
  81. package/dist/stores/memcached-store.d.ts.map +1 -0
  82. package/dist/stores/memcached-store.js +211 -0
  83. package/dist/stores/memcached-store.js.map +1 -0
  84. package/dist/stores/redis-store.d.ts +19 -0
  85. package/dist/stores/redis-store.d.ts.map +1 -0
  86. package/dist/stores/redis-store.js +97 -0
  87. package/dist/stores/redis-store.js.map +1 -0
  88. package/dist/stores/types.d.ts +11 -0
  89. package/dist/stores/types.d.ts.map +1 -0
  90. package/dist/stores/types.js +3 -0
  91. package/dist/stores/types.js.map +1 -0
  92. package/dist/types/index.d.ts +94 -5
  93. package/dist/types/index.d.ts.map +1 -1
  94. package/dist/utils/defaults.d.ts +8 -0
  95. package/dist/utils/defaults.d.ts.map +1 -0
  96. package/dist/utils/defaults.js +100 -0
  97. package/dist/utils/defaults.js.map +1 -0
  98. package/dist/utils/limit-execution.d.ts +40 -0
  99. package/dist/utils/limit-execution.d.ts.map +1 -0
  100. package/dist/utils/limit-execution.js +50 -0
  101. package/dist/utils/limit-execution.js.map +1 -0
  102. package/dist/utils/memcached.d.ts +12 -0
  103. package/dist/utils/memcached.d.ts.map +1 -0
  104. package/dist/utils/memcached.js +103 -0
  105. package/dist/utils/memcached.js.map +1 -0
  106. package/dist/utils/metrics.d.ts +21 -0
  107. package/dist/utils/metrics.d.ts.map +1 -0
  108. package/dist/utils/metrics.js +99 -0
  109. package/dist/utils/metrics.js.map +1 -0
  110. package/dist/utils/redis.d.ts +5 -1
  111. package/dist/utils/redis.d.ts.map +1 -1
  112. package/dist/utils/redis.js +38 -3
  113. package/dist/utils/redis.js.map +1 -1
  114. package/dist/utils/scripts.d.ts +16 -2
  115. package/dist/utils/scripts.d.ts.map +1 -1
  116. package/dist/utils/scripts.js +79 -33
  117. package/dist/utils/scripts.js.map +1 -1
  118. package/package.json +86 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # limitly
2
2
 
3
- Distributed, Redis-powered rate limiting for Express and Fastify.
3
+ Distributed, Redis-powered rate limiting for Express, Fastify, Hono, Koa, Bun, and NestJS.
4
4
 
5
5
  > Express-rate-limit, but distributed, Redis-powered, and production ready.
6
6
 
@@ -8,6 +8,28 @@ Distributed, Redis-powered rate limiting for Express and Fastify.
8
8
 
9
9
  ```bash
10
10
  npm install limitly ioredis
11
+
12
+ # Memcached backend
13
+ npm install limitly memcached
14
+
15
+ # OpenTelemetry
16
+ npm install limitly ioredis @opentelemetry/api
17
+
18
+ # Prometheus
19
+ npm install limitly ioredis prom-client
20
+
21
+ # NestJS
22
+ npm install limitly ioredis @nestjs/common @nestjs/core
23
+ ```
24
+
25
+ Framework-specific subpath imports are available for tree-shaking:
26
+
27
+ ```typescript
28
+ import { createExpressMiddleware } from "limitly/express";
29
+ import { createFastifyPlugin } from "limitly/fastify";
30
+ import { createHonoMiddleware } from "limitly/hono";
31
+ import { createKoaMiddleware } from "limitly/koa";
32
+ import { createBunMiddleware } from "limitly/bun";
11
33
  ```
12
34
 
13
35
  ## Quick Start
@@ -23,14 +45,7 @@ const app = express();
23
45
  const redis = new Redis();
24
46
  const limiter = createLimiter({ redis });
25
47
 
26
- app.use(
27
- limiter.middleware({
28
- algorithm: "sliding-window",
29
- limit: 100,
30
- window: 60,
31
- key: (req) => req.ip,
32
- }),
33
- );
48
+ app.use(limiter.middleware({ key: (req) => req.ip }));
34
49
  ```
35
50
 
36
51
  ### Fastify
@@ -51,11 +66,179 @@ await fastify.register(limiter.fastifyPlugin, {
51
66
  });
52
67
  ```
53
68
 
69
+ ### Hono
70
+
71
+ ```typescript
72
+ import { Hono } from "hono";
73
+ import Redis from "ioredis";
74
+ import { createLimiter } from "limitly";
75
+
76
+ const app = new Hono();
77
+ const limiter = createLimiter({ redis: new Redis() });
78
+
79
+ app.use("*", limiter.honoMiddleware({ key: (c) => c.req.header("x-api-key") }));
80
+ ```
81
+
82
+ ### Koa
83
+
84
+ ```typescript
85
+ import Koa from "koa";
86
+ import Redis from "ioredis";
87
+ import { createLimiter } from "limitly";
88
+
89
+ const app = new Koa();
90
+ const limiter = createLimiter({ redis: new Redis() });
91
+
92
+ app.use(limiter.koaMiddleware({ key: (ctx) => ctx.ip }));
93
+ ```
94
+
95
+ ### Bun
96
+
97
+ ```typescript
98
+ import Redis from "ioredis";
99
+ import { composeBunHandler, createLimiter } from "limitly";
100
+
101
+ const limiter = createLimiter({ redis: new Redis() });
102
+
103
+ const rateLimit = limiter.bunMiddleware({
104
+ key: (req) => req.headers.get("x-api-key"),
105
+ });
106
+
107
+ const fetch = composeBunHandler(
108
+ [rateLimit],
109
+ () => Response.json({ message: "Hello Bun!" })
110
+ );
111
+
112
+ Bun.serve({ port: 3000, fetch });
113
+ ```
114
+
115
+ ### NestJS
116
+
117
+ ```typescript
118
+ import { Module } from "@nestjs/common";
119
+ import { APP_GUARD } from "@nestjs/core";
120
+ import Redis from "ioredis";
121
+ import { createLimiter } from "limitly";
122
+ import { RateLimit } from "limitly/nest";
123
+
124
+ const limiter = createLimiter({ redis: new Redis() });
125
+ const NestGuard = limiter.nestGuard({
126
+ limit: 100,
127
+ window: 60,
128
+ key: (req) => req.ip,
129
+ });
130
+
131
+ @Module({
132
+ providers: [{ provide: APP_GUARD, useClass: NestGuard }],
133
+ })
134
+ export class AppModule {}
135
+ ```
136
+
137
+ Per-route limits with `@RateLimit()`:
138
+
139
+ ```typescript
140
+ import { Controller, Get } from "@nestjs/common";
141
+ import { RateLimit } from "limitly/nest";
142
+
143
+ @Controller("api")
144
+ export class ApiController {
145
+ @Get()
146
+ @RateLimit({ limit: 10, window: 60 })
147
+ findAll() {
148
+ return { ok: true };
149
+ }
150
+ }
151
+ ```
152
+
153
+ Module helper (like `redisLimitPlugin` for Fastify):
154
+
155
+ ```typescript
156
+ import { limitlyNestModule } from "limitly/nest";
157
+
158
+ @Module({
159
+ imports: [
160
+ limitlyNestModule({
161
+ limiter,
162
+ algorithm: "token-bucket",
163
+ capacity: 50,
164
+ refillRate: 10,
165
+ global: true,
166
+ }),
167
+ ],
168
+ })
169
+ export class AppModule {}
170
+ ```
171
+
172
+ ## Default Algorithm
173
+
174
+ If you omit algorithm options, limitly uses **GCRA** with `limit: 100` and `window: 60`:
175
+
176
+ ```typescript
177
+ const limiter = createLimiter({ redis: new Redis() });
178
+
179
+ app.use(limiter.middleware({ key: (req) => req.ip }));
180
+
181
+ // equivalent to:
182
+ app.use(
183
+ limiter.middleware({
184
+ algorithm: "gcra",
185
+ limit: 100,
186
+ window: 60,
187
+ key: (req) => req.ip,
188
+ })
189
+ );
190
+ ```
191
+
192
+ Set limiter-wide defaults:
193
+
194
+ ```typescript
195
+ const limiter = createLimiter({
196
+ redis: new Redis(),
197
+ default: { limit: 50, window: 30 },
198
+ });
199
+
200
+ app.use(limiter.middleware({ key: (req) => req.ip }));
201
+ ```
202
+
203
+ ## Programmatic Checks
204
+
205
+ Use `limiter.check()` outside middleware — useful for login guards, background jobs, or custom response handling:
206
+
207
+ ```typescript
208
+ const result = await limiter.check(req.ip ?? "unknown", {
209
+ limit: 5,
210
+ window: 10,
211
+ });
212
+
213
+ if (!result.allowed) {
214
+ return res.status(429).json({
215
+ error: "Too many attempts",
216
+ retryAfter: result.retryAfter,
217
+ });
218
+ }
219
+ ```
220
+
221
+ `check()` respects limiter-wide defaults, `failOpen`, and global `onMetrics` hooks.
222
+
54
223
  ## Algorithms
55
224
 
225
+ ### GCRA (default)
226
+
227
+ Generic Cell Rate Algorithm — smooth rate limiting with controlled bursts. Uses a single TAT (theoretical arrival time) per key, so it's memory-efficient compared to sliding window:
228
+
229
+ ```typescript
230
+ limiter.middleware({
231
+ limit: 100,
232
+ window: 60, // seconds — algorithm defaults to gcra
233
+ key: (req) => req.ip,
234
+ });
235
+ ```
236
+
237
+ GCRA enforces an average rate of `limit / window` while allowing short bursts up to `limit`. Ideal when you want token-bucket-like behavior with predictable storage costs.
238
+
56
239
  ### Sliding Window
57
240
 
58
- Uses Redis Sorted Sets for precise rate limiting over a rolling time window.
241
+ Uses Redis Sorted Sets (or Memcached counters) for rate limiting over a rolling time window.
59
242
 
60
243
  ```typescript
61
244
  limiter.middleware({
@@ -77,30 +260,117 @@ limiter.middleware({
77
260
  });
78
261
  ```
79
262
 
80
- ## Redis Connection
263
+ ### Concurrency
264
+
265
+ Limits simultaneous in-flight requests per key. Slots are acquired on entry and released when the response finishes (middleware handles this automatically):
266
+
267
+ ```typescript
268
+ limiter.middleware({
269
+ algorithm: "concurrency",
270
+ limit: 10, // max concurrent requests
271
+ ttl: 300, // lease TTL in seconds for stale slot cleanup
272
+ key: (req) => req.ip,
273
+ });
274
+ ```
275
+
276
+ For manual acquire/release outside middleware:
277
+
278
+ ```typescript
279
+ const acquired = await limiter.acquire("job-42", {
280
+ algorithm: "concurrency",
281
+ limit: 5,
282
+ ttl: 120,
283
+ });
284
+
285
+ if (!acquired.allowed) {
286
+ throw new Error("Too many concurrent jobs");
287
+ }
288
+
289
+ try {
290
+ await runJob();
291
+ } finally {
292
+ await limiter.release("job-42", acquired.slotId!, {
293
+ algorithm: "concurrency",
294
+ limit: 5,
295
+ ttl: 120,
296
+ });
297
+ }
298
+ ```
299
+
300
+ Storage keys use the `cc:` prefix: `{keyPrefix}:cc:{id}`.
301
+
302
+ ## Storage Backends
303
+
304
+ limitly supports Redis, Valkey, DragonflyDB (Redis-compatible), and Memcached.
81
305
 
82
- Supports multiple connection modes:
306
+ ### Redis / Valkey / DragonflyDB
307
+
308
+ Redis-compatible backends use atomic Lua scripts (sorted sets + hashes). Connect with `ioredis`:
83
309
 
84
310
  ```typescript
85
- // Existing Redis instance
311
+ import Redis from "ioredis";
312
+
313
+ // Redis
86
314
  createLimiter({ redis: new Redis() });
87
315
 
88
- // Connection URL
89
- createLimiter({ redis: "redis://localhost:6379" });
316
+ // Valkey
317
+ createLimiter({ store: "valkey", redis: "redis://localhost:6379" });
90
318
 
91
- // Options object
92
- createLimiter({ redis: { host: "localhost", port: 6379 } });
319
+ // DragonflyDB
320
+ createLimiter({ store: "dragonfly", redis: { host: "localhost", port: 6379 } });
93
321
 
94
- // Cluster
322
+ // Cluster (auto-pipelining, script warmup, master reads)
95
323
  createLimiter({
324
+ store: "redis",
96
325
  redis: {
97
- nodes: [{ host: "127.0.0.1", port: 7000 }],
326
+ nodes: [
327
+ { host: "127.0.0.1", port: 7000 },
328
+ { host: "127.0.0.1", port: 7001 },
329
+ ],
330
+ options: {
331
+ redisOptions: { password: "secret" },
332
+ },
98
333
  },
99
334
  });
335
+
336
+ // Optional: pin all keys to one slot (use only when you need co-location)
337
+ createLimiter({
338
+ redis: { nodes: [{ host: "127.0.0.1", port: 7000 }] },
339
+ hashTag: "limitly",
340
+ });
100
341
  ```
101
342
 
343
+ Cluster optimizations built into limitly:
344
+
345
+ - **ioredis `defineCommand`** — Lua scripts are registered cluster-aware, avoiding per-node `NOSCRIPT` fallbacks
346
+ - **Script warmup** — scripts are preloaded on all master nodes at startup (disable with `warmupScripts: false`)
347
+ - **Auto-pipelining** — enabled by default for higher throughput under concurrent load
348
+ - **Hash tags** — optional `hashTag` for slot pinning; leave unset to spread keys across slots (recommended)
349
+ - **Master reads** — `scaleReads: "master"` by default so checks always hit the authoritative node
350
+
351
+ ### Memcached
352
+
353
+ Memcached uses counter-based sliding window and CAS token bucket (no Lua required):
354
+
355
+ ```typescript
356
+ import Memcached from "memcached";
357
+
358
+ createLimiter({
359
+ store: "memcached",
360
+ memcached: "localhost:11211",
361
+ });
362
+
363
+ // Or pass an existing client
364
+ createLimiter({ memcached: new Memcached(["localhost:11211"]) });
365
+ ```
366
+
367
+ > Memcached sliding window uses a weighted two-window counter (high accuracy, no sorted sets).
368
+ > Token bucket uses `gets`/`cas` for atomic updates.
369
+
102
370
  ## Key Extraction
103
371
 
372
+ The `key` option identifies **who** is being rate limited (per request):
373
+
104
374
  ```typescript
105
375
  // IP-based
106
376
  key: (req) => req.ip;
@@ -112,6 +382,31 @@ key: (req) => req.headers["x-api-key"];
112
382
  key: (req) => req.user.id;
113
383
  ```
114
384
 
385
+ ## Storage Key Prefix
386
+
387
+ The `keyPrefix` option controls **where** counters are stored in Redis/Memcached.
388
+ Default is `limitly`:
389
+
390
+ ```typescript
391
+ createLimiter({ redis: new Redis() });
392
+ // stores keys like: limitly:sw:203.0.113.1
393
+
394
+ createLimiter({
395
+ redis: new Redis(),
396
+ keyPrefix: "myapp:prod",
397
+ });
398
+ // stores keys like: myapp:prod:sw:203.0.113.1
399
+ ```
400
+
401
+ Key format:
402
+
403
+ ```
404
+ {keyPrefix}:sw:{id} — sliding window
405
+ {keyPrefix}:tb:{id} — token bucket
406
+ {keyPrefix}:gcra:{id} — GCRA
407
+ {keyPrefix}:cc:{id} — concurrency
408
+ ```
409
+
115
410
  ## Response Headers
116
411
 
117
412
  Standard rate limit headers are set by default:
@@ -129,15 +424,166 @@ Disable with `headers: false`.
129
424
 
130
425
  ```typescript
131
426
  limiter.middleware({
132
- algorithm: "sliding-window",
133
- limit: 100,
134
- window: 60,
135
427
  onLimitReached(req, res) {
136
428
  res.status(429).json({ code: "RATE_LIMITED" });
137
429
  },
138
430
  });
139
431
  ```
140
432
 
433
+ ## Metrics
434
+
435
+ Emit observability events via the `onMetrics` hook. Set it globally on the limiter or per middleware/route:
436
+
437
+ ```typescript
438
+ const limiter = createLimiter({
439
+ redis: new Redis(),
440
+ onMetrics: (event) => {
441
+ console.log(event.type, event.key, `${event.durationMs.toFixed(2)}ms`);
442
+ },
443
+ });
444
+
445
+ // Per-route override
446
+ limiter.middleware({
447
+ onMetrics: (event) => metrics.increment(`ratelimit.${event.type}`),
448
+ });
449
+ ```
450
+
451
+ Event types:
452
+
453
+ | Type | When |
454
+ |------|------|
455
+ | `allowed` | Request passed the rate limit check |
456
+ | `blocked` | Request exceeded the limit |
457
+ | `error` | Store operation failed |
458
+ | `fail_open` | Store failed but `failOpen: true` allowed traffic through |
459
+
460
+ Each event includes `key`, `algorithm`, `durationMs`, and optionally `store`, `context` (the request object), and `result` or `error` depending on type.
461
+
462
+ Multiple hooks are supported:
463
+
464
+ ```typescript
465
+ onMetrics: [logToConsole, sendToDatadog]
466
+ ```
467
+
468
+ `limiter.check()` and all framework middleware use the same metrics pipeline.
469
+
470
+ ## OpenTelemetry
471
+
472
+ Use the `limitly/otel` helpers to emit OTEL metrics and traces. Only `@opentelemetry/api` is required — bring your own SDK/exporter:
473
+
474
+ ```typescript
475
+ import Redis from "ioredis";
476
+ import { createLimiter } from "limitly";
477
+ import { createOpenTelemetryInstrumentation } from "limitly/otel";
478
+
479
+ const otel = createOpenTelemetryInstrumentation();
480
+
481
+ const limiter = createLimiter({
482
+ redis: new Redis(),
483
+ tracer: otel.tracer,
484
+ onMetrics: otel.onMetrics,
485
+ });
486
+
487
+ app.use(limiter.middleware({ key: (req) => req.ip }));
488
+ ```
489
+
490
+ `createOpenTelemetryInstrumentation()` returns:
491
+
492
+ - **`tracer`** — creates `limitly.check` spans with `limitly.algorithm`, `limitly.store`, `limitly.outcome`, `limitly.limit`, and `limitly.remaining` attributes
493
+ - **`onMetrics`** — records `limitly.check.total` (counter) and `limitly.check.duration` (histogram, ms)
494
+
495
+ Use the pieces independently when needed:
496
+
497
+ ```typescript
498
+ import {
499
+ createOpenTelemetryMetricsHook,
500
+ createOpenTelemetryTracer,
501
+ } from "limitly/otel";
502
+
503
+ const limiter = createLimiter({
504
+ redis: new Redis(),
505
+ tracer: createOpenTelemetryTracer(),
506
+ onMetrics: createOpenTelemetryMetricsHook({ includeKey: false }),
507
+ });
508
+ ```
509
+
510
+ Combine with custom hooks:
511
+
512
+ ```typescript
513
+ onMetrics: [otel.onMetrics, customHook]
514
+ ```
515
+
516
+ ## Prometheus
517
+
518
+ Use `limitly/prometheus` to expose rate limit metrics for scraping:
519
+
520
+ ```typescript
521
+ import express from "express";
522
+ import Redis from "ioredis";
523
+ import { createLimiter } from "limitly";
524
+ import {
525
+ createPrometheusExporter,
526
+ createPrometheusHandler,
527
+ } from "limitly/prometheus";
528
+
529
+ const app = express();
530
+ const prometheus = createPrometheusExporter();
531
+
532
+ const limiter = createLimiter({
533
+ redis: new Redis(),
534
+ onMetrics: prometheus.onMetrics,
535
+ });
536
+
537
+ app.use(limiter.middleware({ key: (req) => req.ip }));
538
+ app.get("/metrics", createPrometheusHandler(prometheus));
539
+ ```
540
+
541
+ Metrics exposed:
542
+
543
+ | Metric | Type | Labels |
544
+ |--------|------|--------|
545
+ | `limitly_check_total` | Counter | `algorithm`, `outcome`, `store` |
546
+ | `limitly_check_duration_seconds` | Histogram | `algorithm`, `outcome`, `store` |
547
+
548
+ `outcome` is one of `allowed`, `blocked`, `error`, or `fail_open`.
549
+
550
+ Options:
551
+
552
+ | Option | Default | Description |
553
+ |--------|---------|-------------|
554
+ | `register` | new `Registry` | Prometheus registry |
555
+ | `prefix` | `"limitly"` | Metric name prefix |
556
+ | `includeKey` | `false` | Add rate limit key as a label (high cardinality) |
557
+ | `durationBuckets` | `[0.001, 0.005, 0.01, …, 1]` | Histogram buckets (seconds) |
558
+
559
+ Use a shared registry with your existing metrics:
560
+
561
+ ```typescript
562
+ import { Registry } from "prom-client";
563
+
564
+ const register = new Registry();
565
+ const prometheus = createPrometheusExporter({ register });
566
+
567
+ onMetrics: [prometheus.onMetrics, otherHook];
568
+ ```
569
+
570
+ Scrape manually without HTTP middleware:
571
+
572
+ ```typescript
573
+ const body = await prometheus.getMetrics();
574
+ ```
575
+
576
+ Use `createPrometheusMetricsHook` when you only need the `onMetrics` hook without the HTTP handler:
577
+
578
+ ```typescript
579
+ import { createPrometheusMetricsHook } from "limitly/prometheus";
580
+
581
+ const limiter = createLimiter({
582
+ redis: new Redis(),
583
+ onMetrics: createPrometheusMetricsHook({ prefix: "api" }),
584
+ });
585
+ ```
586
+
141
587
  ## Fail Open / Closed
142
588
 
143
589
  When Redis is unavailable:
@@ -157,9 +603,12 @@ Implement custom algorithms with the `RateLimitStrategy` interface:
157
603
  ```typescript
158
604
  interface RateLimitStrategy {
159
605
  consume(key: string): Promise<RateLimitResult>;
606
+ release?(key: string, slotId: string): Promise<void>;
160
607
  }
161
608
  ```
162
609
 
610
+ `release` is optional — only needed for concurrency-style strategies that hold a slot until the response finishes.
611
+
163
612
  ## Performance
164
613
 
165
614
  - Atomic operations via Lua scripts (`EVALSHA`)
@@ -0,0 +1,11 @@
1
+ import type { ConcurrencyConfig, RateLimitResult, RateLimitStrategy } from "../types";
2
+ import type { RateLimitStore } from "../stores/types";
3
+ export declare class ConcurrencyStrategy implements RateLimitStrategy {
4
+ private readonly store;
5
+ private readonly limit;
6
+ private readonly ttl;
7
+ constructor(store: RateLimitStore, config: ConcurrencyConfig);
8
+ consume(key: string): Promise<RateLimitResult>;
9
+ release(key: string, slotId: string): Promise<void>;
10
+ }
11
+ //# sourceMappingURL=concurrency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"concurrency.d.ts","sourceRoot":"","sources":["../../src/algorithms/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGtD,qBAAa,mBAAoB,YAAW,iBAAiB;IAC3D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;gBAEjB,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB;IAMtD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAI9C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG1D"}
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConcurrencyStrategy = void 0;
4
+ const defaults_1 = require("../utils/defaults");
5
+ class ConcurrencyStrategy {
6
+ constructor(store, config) {
7
+ this.store = store;
8
+ this.limit = config.limit;
9
+ this.ttl = config.ttl ?? defaults_1.DEFAULT_CONCURRENCY.ttl;
10
+ }
11
+ async consume(key) {
12
+ return this.store.concurrencyAcquire(key, this.limit, this.ttl);
13
+ }
14
+ async release(key, slotId) {
15
+ await this.store.concurrencyRelease(key, slotId);
16
+ }
17
+ }
18
+ exports.ConcurrencyStrategy = ConcurrencyStrategy;
19
+ //# sourceMappingURL=concurrency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../../src/algorithms/concurrency.ts"],"names":[],"mappings":";;;AAMA,gDAAwD;AAExD,MAAa,mBAAmB;IAK9B,YAAY,KAAqB,EAAE,MAAyB;QAC1D,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,IAAI,8BAAmB,CAAC,GAAI,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IAClE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,MAAc;QACvC,MAAM,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;CACF;AAlBD,kDAkBC"}
@@ -1,3 +1,4 @@
1
- import type { AlgorithmConfig, RateLimitStrategy, RedisClient } from "../types";
2
- export declare function createStrategy(redis: RedisClient, config: AlgorithmConfig, keyPrefix?: string): RateLimitStrategy;
1
+ import type { AlgorithmConfig, RateLimitStrategy } from "../types";
2
+ import type { RateLimitStore } from "../stores/types";
3
+ export declare function createStrategy(store: RateLimitStore, config: AlgorithmConfig): RateLimitStrategy;
3
4
  //# sourceMappingURL=factory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACZ,MAAM,UAAU,CAAC;AAIlB,wBAAgB,cAAc,CAC5B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,eAAe,EACvB,SAAS,CAAC,EAAE,MAAM,GACjB,iBAAiB,CAanB"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAMtD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,eAAe,GACtB,iBAAiB,CAiBnB"}
@@ -1,14 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createStrategy = createStrategy;
4
+ const concurrency_1 = require("./concurrency");
5
+ const gcra_1 = require("./gcra");
4
6
  const sliding_window_1 = require("./sliding-window");
5
7
  const token_bucket_1 = require("./token-bucket");
6
- function createStrategy(redis, config, keyPrefix) {
8
+ function createStrategy(store, config) {
7
9
  switch (config.algorithm) {
8
10
  case "sliding-window":
9
- return new sliding_window_1.SlidingWindowStrategy(redis, config, keyPrefix);
11
+ return new sliding_window_1.SlidingWindowStrategy(store, config);
10
12
  case "token-bucket":
11
- return new token_bucket_1.TokenBucketStrategy(redis, config, keyPrefix);
13
+ return new token_bucket_1.TokenBucketStrategy(store, config);
14
+ case "concurrency":
15
+ return new concurrency_1.ConcurrencyStrategy(store, config);
16
+ case "gcra":
17
+ return new gcra_1.GcraStrategy(store, config);
12
18
  default: {
13
19
  const exhaustive = config;
14
20
  throw new Error(`Unknown algorithm: ${exhaustive.algorithm}`);
@@ -1 +1 @@
1
- {"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":";;AAQA,wCAiBC;AApBD,qDAAyD;AACzD,iDAAqD;AAErD,SAAgB,cAAc,CAC5B,KAAkB,EAClB,MAAuB,EACvB,SAAkB;IAElB,QAAQ,MAAM,CAAC,SAAS,EAAE,CAAC;QACzB,KAAK,gBAAgB;YACnB,OAAO,IAAI,sCAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;QAC7D,KAAK,cAAc;YACjB,OAAO,IAAI,kCAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;QAC3D,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC;YACjC,MAAM,IAAI,KAAK,CACb,sBAAuB,UAA8B,CAAC,SAAS,EAAE,CAClE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":";;AAOA,wCAoBC;AAzBD,+CAAoD;AACpD,iCAAsC;AACtC,qDAAyD;AACzD,iDAAqD;AAErD,SAAgB,cAAc,CAC5B,KAAqB,EACrB,MAAuB;IAEvB,QAAQ,MAAM,CAAC,SAAS,EAAE,CAAC;QACzB,KAAK,gBAAgB;YACnB,OAAO,IAAI,sCAAqB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAClD,KAAK,cAAc;YACjB,OAAO,IAAI,kCAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAChD,KAAK,aAAa;YAChB,OAAO,IAAI,iCAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAChD,KAAK,MAAM;YACT,OAAO,IAAI,mBAAY,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QACzC,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC;YACjC,MAAM,IAAI,KAAK,CACb,sBAAuB,UAA8B,CAAC,SAAS,EAAE,CAClE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { GcraConfig, RateLimitResult, RateLimitStrategy } from "../types";
2
+ import type { RateLimitStore } from "../stores/types";
3
+ export declare class GcraStrategy implements RateLimitStrategy {
4
+ private readonly store;
5
+ private readonly limit;
6
+ private readonly window;
7
+ constructor(store: RateLimitStore, config: GcraConfig);
8
+ consume(key: string): Promise<RateLimitResult>;
9
+ }
10
+ //# sourceMappingURL=gcra.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcra.d.ts","sourceRoot":"","sources":["../../src/algorithms/gcra.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,qBAAa,YAAa,YAAW,iBAAiB;IACpD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,UAAU;IAM/C,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CAGrD"}
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GcraStrategy = void 0;
4
+ class GcraStrategy {
5
+ constructor(store, config) {
6
+ this.store = store;
7
+ this.limit = config.limit;
8
+ this.window = config.window;
9
+ }
10
+ async consume(key) {
11
+ return this.store.gcra(key, this.limit, this.window);
12
+ }
13
+ }
14
+ exports.GcraStrategy = GcraStrategy;
15
+ //# sourceMappingURL=gcra.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcra.js","sourceRoot":"","sources":["../../src/algorithms/gcra.ts"],"names":[],"mappings":";;;AAGA,MAAa,YAAY;IAKvB,YAAY,KAAqB,EAAE,MAAkB;QACnD,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACvD,CAAC;CACF;AAdD,oCAcC"}