@xyph3r/rate-limiter 0.1.0 → 0.1.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 (2) hide show
  1. package/README.md +268 -92
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,17 +1,21 @@
1
1
  # `@xyph3r/rate-limiter`
2
2
 
3
- Framework-agnostic rate limiting for Node.js and fetch-based runtimes with two clear goals:
3
+ Framework-agnostic rate limiting for Node.js and fetch-based runtimes.
4
4
 
5
- - keep the core API small enough to use without ceremony
6
- - keep the internals structured enough to stay maintainable when requirements change
5
+ This package is for the common cases people actually need:
7
6
 
8
- The package is organized around a few deliberate design choices:
7
+ - protect a public API by IP or API key
8
+ - slow down abusive login attempts
9
+ - apply per-tenant or per-user quotas
10
+ - add rate-limit headers to HTTP responses
11
+ - use Redis when you run more than one app instance
12
+
13
+ The package is intentionally small:
9
14
 
10
15
  - `Strategy`: sliding window and token bucket are swappable algorithms
11
- - `Builder`: fluent construction for readable setup
12
- - `Decorator`: optional Express and Fastify adapters wrap the core
13
- - `Decorator`: fetch/Hono/Next/Nest integrations stay outside the core
14
- - `Proxy`: short-lived decision caching for hot paths
16
+ - `Builder`: setup stays readable as configuration grows
17
+ - `Decorator`: framework adapters wrap the core without coupling it to Express, Fastify, Hono, or Next
18
+ - `Proxy`: optional short-lived caching reduces repeated checks on hot paths
15
19
 
16
20
  ## Install
17
21
 
@@ -19,50 +23,69 @@ The package is organized around a few deliberate design choices:
19
23
  npm install @xyph3r/rate-limiter
20
24
  ```
21
25
 
22
- ## Quick start
26
+ or
23
27
 
24
- ```ts
25
- import { createExpressRateLimit, RateLimiterBuilder } from "@xyph3r/rate-limiter";
28
+ ```bash
29
+ bun add @xyph3r/rate-limiter
30
+ ```
26
31
 
27
- const limiter = new RateLimiterBuilder()
28
- .forSlidingWindow({ limit: 100, windowMs: 60_000 })
29
- .withMemoryStore()
30
- .withKeyPrefix("api")
31
- .build();
32
+ ## When to use which algorithm
32
33
 
33
- app.use(createExpressRateLimit(limiter));
34
- ```
34
+ ### Sliding window
35
+
36
+ Use sliding window when you want a straightforward limit like:
35
37
 
36
- ## Bun
38
+ - `100 requests per minute`
39
+ - `5 login attempts per 10 minutes`
40
+ - `30 requests per second`
41
+
42
+ This is usually the default choice for HTTP APIs.
43
+
44
+ ### Token bucket
37
45
 
38
- For Bun's native `fetch` handler, use the shared fetch adapter:
46
+ Use token bucket when you want bursts to be allowed but still controlled over time.
47
+
48
+ Examples:
49
+
50
+ - allow a short burst of `20` requests, then refill at `5/second`
51
+ - smooth traffic for jobs or background workers
52
+ - tolerate small spikes without rejecting immediately
53
+
54
+ ## Production advice
55
+
56
+ - `MemoryStore` is process-local. Use it for local dev, tests, or single-instance apps.
57
+ - `RedisStore` is the right choice for multi-instance deployments.
58
+ - Rate limiting only works as well as your key choice. Pick a stable identity: IP, API key, tenant ID, or user ID.
59
+
60
+ ## Quick start
39
61
 
40
62
  ```ts
41
- import { createFetchRateLimit, RateLimiterBuilder } from "@xyph3r/rate-limiter";
63
+ import express from "express";
64
+ import {
65
+ createExpressRateLimit,
66
+ RateLimiterBuilder,
67
+ } from "@xyph3r/rate-limiter";
68
+
69
+ const app = express();
42
70
 
43
71
  const limiter = new RateLimiterBuilder()
44
72
  .forSlidingWindow({ limit: 100, windowMs: 60_000 })
45
73
  .withMemoryStore()
74
+ .withKeyPrefix("api")
46
75
  .build();
47
76
 
48
- const withRateLimit = createFetchRateLimit(limiter);
49
-
50
- Bun.serve({
51
- fetch: withRateLimit(async () => {
52
- return Response.json({ ok: true });
53
- }),
54
- });
77
+ app.use(createExpressRateLimit(limiter));
55
78
  ```
56
79
 
57
80
  ## Core usage
58
81
 
59
- ### Sliding window
82
+ Use the core API when you want rate-limit decisions outside HTTP middleware.
60
83
 
61
84
  ```ts
62
- import { MemoryStore, RateLimiterBuilder } from "@xyph3r/rate-limiter";
85
+ import { RateLimiterBuilder } from "@xyph3r/rate-limiter";
63
86
 
64
87
  const limiter = new RateLimiterBuilder()
65
- .useStore(new MemoryStore())
88
+ .withMemoryStore()
66
89
  .forSlidingWindow({
67
90
  limit: 10,
68
91
  windowMs: 1_000,
@@ -76,7 +99,7 @@ if (!decision.allowed) {
76
99
  }
77
100
  ```
78
101
 
79
- ### Token bucket
102
+ ### Token bucket example
80
103
 
81
104
  ```ts
82
105
  import { createRateLimiter } from "@xyph3r/rate-limiter";
@@ -92,7 +115,7 @@ const limiter = createRateLimiter({
92
115
 
93
116
  ## Redis-backed usage
94
117
 
95
- The package does not force a Redis client. Instead, it ships lightweight executors for the two common interfaces.
118
+ The package does not force a Redis client. It ships small executors for the common client shapes.
96
119
 
97
120
  ### `node-redis`
98
121
 
@@ -104,13 +127,11 @@ import {
104
127
  RateLimiterBuilder,
105
128
  } from "@xyph3r/rate-limiter";
106
129
 
107
- const client = createClient();
130
+ const client = createClient({ url: process.env.REDIS_URL });
108
131
  await client.connect();
109
132
 
110
- const store = new RedisStore(createNodeRedisExecutor(client));
111
-
112
133
  const limiter = new RateLimiterBuilder()
113
- .useStore(store)
134
+ .useStore(new RedisStore(createNodeRedisExecutor(client)))
114
135
  .forSlidingWindow({ limit: 200, windowMs: 60_000 })
115
136
  .build();
116
137
  ```
@@ -126,114 +147,186 @@ import {
126
147
  } from "@xyph3r/rate-limiter";
127
148
 
128
149
  const client = new Redis(process.env.REDIS_URL!);
129
- const store = new RedisStore(createIORedisExecutor(client));
150
+
151
+ const limiter = new RateLimiterBuilder()
152
+ .useStore(new RedisStore(createIORedisExecutor(client)))
153
+ .forSlidingWindow({ limit: 200, windowMs: 60_000 })
154
+ .build();
130
155
  ```
131
156
 
132
- ## Simple factory
157
+ ## Framework usage
158
+
159
+ ### Express
133
160
 
134
- If you prefer a plain config object over the builder:
161
+ Use `createExpressRateLimit()` as middleware.
135
162
 
136
163
  ```ts
137
- import { createRateLimiter } from "@xyph3r/rate-limiter";
164
+ import express from "express";
165
+ import {
166
+ createExpressRateLimit,
167
+ RateLimiterBuilder,
168
+ } from "@xyph3r/rate-limiter";
138
169
 
139
- const limiter = createRateLimiter({
140
- algorithm: "sliding-window",
141
- limit: 50,
142
- windowMs: 10_000,
143
- keyPrefix: "public-api",
144
- cacheMs: 25,
145
- });
170
+ const app = express();
171
+
172
+ const limiter = new RateLimiterBuilder()
173
+ .withMemoryStore()
174
+ .forSlidingWindow({ limit: 50, windowMs: 60_000 })
175
+ .build();
176
+
177
+ app.use(
178
+ createExpressRateLimit(limiter, {
179
+ key: (request) =>
180
+ request.headers["x-api-key"] as string || request.ip || "anonymous",
181
+ skip: (request) => request.headers["x-internal-call"] === "1",
182
+ }),
183
+ );
146
184
  ```
147
185
 
148
- ## Express adapter
186
+ Use this for:
187
+
188
+ - public REST APIs
189
+ - login and auth routes
190
+ - per-customer API key limits
191
+
192
+ ### Fastify
193
+
194
+ Use `createFastifyRateLimit()` in `preHandler`.
149
195
 
150
196
  ```ts
151
- import express from "express";
197
+ import Fastify from "fastify";
152
198
  import {
153
- createExpressRateLimit,
199
+ createFastifyRateLimit,
154
200
  RateLimiterBuilder,
155
201
  } from "@xyph3r/rate-limiter";
156
202
 
157
- const app = express();
203
+ const app = Fastify();
158
204
 
159
205
  const limiter = new RateLimiterBuilder()
206
+ .withMemoryStore()
160
207
  .forTokenBucket({
161
208
  capacity: 30,
162
209
  refillRate: 10,
163
210
  refillIntervalMs: 1_000,
164
211
  })
165
- .withMemoryStore()
166
212
  .build();
167
213
 
168
- app.use(
169
- createExpressRateLimit(limiter, {
170
- key: (req) => req.headers["x-api-key"] as string,
214
+ app.addHook(
215
+ "preHandler",
216
+ createFastifyRateLimit(limiter, {
217
+ key: (request) =>
218
+ request.headers["x-api-key"] as string || request.ip || "anonymous",
171
219
  }),
172
220
  );
173
221
  ```
174
222
 
175
- ## Hono adapter
223
+ ### Fetch / Bun / standard `Request` handlers
224
+
225
+ Use `createFetchRateLimit()` when your runtime already uses the standard `Request -> Response` shape.
176
226
 
177
227
  ```ts
178
- import { Hono } from "hono";
179
228
  import {
180
- createHonoRateLimit,
229
+ createFetchRateLimit,
181
230
  RateLimiterBuilder,
182
231
  } from "@xyph3r/rate-limiter";
183
232
 
184
- const app = new Hono();
233
+ const limiter = new RateLimiterBuilder()
234
+ .withMemoryStore()
235
+ .forSlidingWindow({ limit: 100, windowMs: 60_000 })
236
+ .build();
237
+
238
+ const withRateLimit = createFetchRateLimit(limiter, {
239
+ key: (request) => request.headers.get("x-api-key") ?? "anonymous",
240
+ });
241
+
242
+ const handler = withRateLimit(async () => {
243
+ return Response.json({ ok: true });
244
+ });
245
+ ```
246
+
247
+ #### Bun example
248
+
249
+ ```ts
250
+ import {
251
+ createFetchRateLimit,
252
+ RateLimiterBuilder,
253
+ } from "@xyph3r/rate-limiter";
185
254
 
186
255
  const limiter = new RateLimiterBuilder()
187
- .forSlidingWindow({ limit: 20, windowMs: 1_000 })
188
256
  .withMemoryStore()
257
+ .forSlidingWindow({ limit: 100, windowMs: 60_000 })
189
258
  .build();
190
259
 
191
- app.use("*", createHonoRateLimit(limiter));
260
+ const withRateLimit = createFetchRateLimit(limiter, {
261
+ key: (request) => request.headers.get("x-forwarded-for") ?? "anonymous",
262
+ });
263
+
264
+ Bun.serve({
265
+ fetch: withRateLimit(async () => Response.json({ ok: true })),
266
+ });
192
267
  ```
193
268
 
194
- ## Fastify adapter
269
+ ### Hono
270
+
271
+ Use `createHonoRateLimit()` as Hono middleware.
195
272
 
196
273
  ```ts
197
- import fastify from "fastify";
274
+ import { Hono } from "hono";
198
275
  import {
199
- createFastifyRateLimit,
276
+ createHonoRateLimit,
200
277
  RateLimiterBuilder,
201
278
  } from "@xyph3r/rate-limiter";
202
279
 
203
- const app = fastify();
280
+ const app = new Hono();
204
281
 
205
282
  const limiter = new RateLimiterBuilder()
206
- .forSlidingWindow({ limit: 5, windowMs: 1_000 })
207
283
  .withMemoryStore()
284
+ .forSlidingWindow({ limit: 20, windowMs: 1_000 })
208
285
  .build();
209
286
 
210
- app.addHook("preHandler", createFastifyRateLimit(limiter));
287
+ app.use(
288
+ "*",
289
+ createHonoRateLimit(limiter, {
290
+ key: (c) => c.req.header("x-api-key") ?? "anonymous",
291
+ }),
292
+ );
211
293
  ```
212
294
 
213
- ## Next.js route handlers
295
+ ### Next.js App Router
214
296
 
215
- For App Router route handlers, wrap the exported handler:
297
+ Use `createNextRateLimit()` to wrap the exported route handler.
216
298
 
217
299
  ```ts
218
- import { createNextRateLimit, RateLimiterBuilder } from "@xyph3r/rate-limiter";
300
+ import {
301
+ createNextRateLimit,
302
+ RateLimiterBuilder,
303
+ } from "@xyph3r/rate-limiter";
219
304
 
220
305
  const limiter = new RateLimiterBuilder()
221
- .forSlidingWindow({ limit: 30, windowMs: 60_000 })
222
306
  .withMemoryStore()
307
+ .forSlidingWindow({ limit: 30, windowMs: 60_000 })
223
308
  .build();
224
309
 
225
- const withRateLimit = createNextRateLimit(limiter);
310
+ const withRateLimit = createNextRateLimit(limiter, {
311
+ key: (request) => request.headers.get("x-api-key") ?? "anonymous",
312
+ });
226
313
 
227
314
  export const GET = withRateLimit(async () => {
228
315
  return Response.json({ ok: true });
229
316
  });
230
317
  ```
231
318
 
232
- You can also use `key(request, context)` to derive limits from route params, session data, or tenant IDs.
319
+ You can also derive the key from route params or tenant context:
320
+
321
+ ```ts
322
+ const withRateLimit = createNextRateLimit(limiter, {
323
+ key: (_request, context: { params: { tenantId: string } }) => context.params.tenantId,
324
+ });
325
+ ```
233
326
 
234
- ## NestJS guard
327
+ ### NestJS
235
328
 
236
- Nest is exposed as a guard-shaped decorator. Pass your own exception from `@nestjs/common` so the framework returns a proper `429`.
329
+ Use `createNestRateLimitGuard()` where a Nest guard fits better than middleware.
237
330
 
238
331
  ```ts
239
332
  import { TooManyRequestsException } from "@nestjs/common";
@@ -243,11 +336,12 @@ import {
243
336
  } from "@xyph3r/rate-limiter";
244
337
 
245
338
  const limiter = new RateLimiterBuilder()
246
- .forSlidingWindow({ limit: 10, windowMs: 1_000 })
247
339
  .withMemoryStore()
340
+ .forSlidingWindow({ limit: 10, windowMs: 1_000 })
248
341
  .build();
249
342
 
250
343
  export const RateLimitGuard = createNestRateLimitGuard(limiter, {
344
+ key: (request) => request.headers?.["x-api-key"] as string || request.ip || "anonymous",
251
345
  errorFactory: (decision) =>
252
346
  new TooManyRequestsException({
253
347
  error: "Too many requests",
@@ -256,6 +350,84 @@ export const RateLimitGuard = createNestRateLimitGuard(limiter, {
256
350
  });
257
351
  ```
258
352
 
353
+ ## Choosing the key
354
+
355
+ A good rate-limit key represents the caller you want to control.
356
+
357
+ Good keys:
358
+
359
+ - client IP for anonymous traffic
360
+ - API key
361
+ - authenticated user ID
362
+ - tenant ID
363
+ - machine or worker ID for background endpoints
364
+
365
+ Bad keys:
366
+
367
+ - a random UUID per request
368
+ - request timestamp
369
+ - request path alone, if many callers share it
370
+
371
+ ## Cost-based limiting
372
+
373
+ Every adapter and the core API support `cost`.
374
+
375
+ Use this when one operation is more expensive than another.
376
+
377
+ ```ts
378
+ const limiter = new RateLimiterBuilder()
379
+ .withMemoryStore()
380
+ .forSlidingWindow({ limit: 100, windowMs: 60_000 })
381
+ .build();
382
+
383
+ await limiter.check("tenant:42", { cost: 5 });
384
+ ```
385
+
386
+ Example uses:
387
+
388
+ - expensive report generation counts as `5`
389
+ - normal read counts as `1`
390
+ - bulk export counts as `10`
391
+
392
+ ## Response headers
393
+
394
+ By default the HTTP adapters set:
395
+
396
+ - `ratelimit-limit`
397
+ - `ratelimit-remaining`
398
+ - `ratelimit-reset`
399
+ - `ratelimit-policy`
400
+ - `retry-after`
401
+
402
+ Disable this with `setHeaders: false` if you need full manual control.
403
+
404
+ ## Factory vs Builder
405
+
406
+ Use the Builder when you want readability and progressive configuration.
407
+
408
+ ```ts
409
+ const limiter = new RateLimiterBuilder()
410
+ .withMemoryStore()
411
+ .forSlidingWindow({ limit: 50, windowMs: 10_000 })
412
+ .withKeyPrefix("public-api")
413
+ .withCache(25)
414
+ .build();
415
+ ```
416
+
417
+ Use `createRateLimiter()` when a plain config object is enough.
418
+
419
+ ```ts
420
+ import { createRateLimiter } from "@xyph3r/rate-limiter";
421
+
422
+ const limiter = createRateLimiter({
423
+ algorithm: "sliding-window",
424
+ limit: 50,
425
+ windowMs: 10_000,
426
+ keyPrefix: "public-api",
427
+ cacheMs: 25,
428
+ });
429
+ ```
430
+
259
431
  ## Public API
260
432
 
261
433
  ### `RateLimiterBuilder`
@@ -265,6 +437,7 @@ export const RateLimitGuard = createNestRateLimitGuard(limiter, {
265
437
  - `.useStore(store)` / `.withMemoryStore()`
266
438
  - `.withKeyPrefix(prefix)`
267
439
  - `.withCache(ttlMs)`
440
+ - `.withClock(now)`
268
441
  - `.build()`
269
442
 
270
443
  ### `RateLimiterLike`
@@ -274,11 +447,12 @@ export const RateLimitGuard = createNestRateLimitGuard(limiter, {
274
447
 
275
448
  ### Adapters
276
449
 
277
- - `createFetchRateLimit()` for Bun or any fetch-native runtime
278
- - `createHonoRateLimit()` for Hono middleware
279
- - `createNextRateLimit()` for Next.js App Router handlers
280
- - `createNestRateLimitGuard()` for NestJS HTTP guards
281
- - `createExpressRateLimit()` and `createFastifyRateLimit()`
450
+ - `createExpressRateLimit()`
451
+ - `createFastifyRateLimit()`
452
+ - `createFetchRateLimit()`
453
+ - `createHonoRateLimit()`
454
+ - `createNextRateLimit()`
455
+ - `createNestRateLimitGuard()`
282
456
 
283
457
  ### Decision fields
284
458
 
@@ -287,24 +461,26 @@ export const RateLimitGuard = createNestRateLimitGuard(limiter, {
287
461
  - `used`
288
462
  - `remaining`
289
463
  - `retryAfterMs`
464
+ - `retryAfterSeconds`
290
465
  - `resetAt`
466
+ - `resetAfterMs`
291
467
  - `policy`
292
468
 
293
469
  ## Notes
294
470
 
295
- - The memory store is process-local. Use Redis for multi-instance deployments.
296
- - Redis execution is atomic because each strategy ships its own Lua program.
297
- - The sliding window implementation uses a weighted previous-window approximation rather than a timestamp log. That keeps storage compact and predictable.
298
- - Bun, Hono, and Next.js all use the fetch-compatible adapter surface under the hood.
471
+ - Sliding window uses a weighted previous-window approximation, not a timestamp log.
472
+ - Redis checks are atomic because each strategy ships its own Lua program.
473
+ - Bun and Next.js share the fetch adapter shape under the hood.
474
+ - The optional cache proxy is only for very short-lived hot-path reuse. It is not a replacement for Redis.
299
475
 
300
476
  ## Publishing
301
477
 
302
- The package is set up so `npm publish` builds `dist/` during `prepack` and runs the test suite during `prepublishOnly`.
478
+ The package is set up so `npm publish` builds `dist/` during `prepack` and runs tests during `prepublishOnly`.
303
479
 
304
480
  Release flow:
305
481
 
306
- 1. Install dev dependencies with `npm install`.
307
- 2. Verify the package locally with `npm test`.
308
- 3. Inspect the publish tarball with `npm pack --dry-run`.
309
- 4. Log in with `npm login` if needed, then confirm the target account with `npm whoami`.
310
- 5. Publish the public scoped package with `npm publish --access public --provenance`.
482
+ 1. Install dependencies with `bun install` or `npm install`.
483
+ 2. Verify with `bun run test` and `bun run build`.
484
+ 3. Inspect the tarball with `npm pack --dry-run`.
485
+ 4. Log in with `npm login` if needed.
486
+ 5. Publish with `npm publish --access public --provenance`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xyph3r/rate-limiter",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-agnostic rate limiting for Node.js with sliding window and token bucket strategies.",
5
5
  "author": "xyph3r",
6
6
  "license": "MIT",