@geekmidas/rate-limit 10.0.0-alpha.99 → 10.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,91 @@
1
+ # @geekmidas/rate-limit
2
+
3
+ ## 10.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies []:
8
+ - @geekmidas/cache@10.0.0
9
+ - @geekmidas/logger@10.0.0
10
+ - @geekmidas/services@10.0.0
11
+
12
+ ## 9.0.2
13
+
14
+ ### Patch Changes
15
+
16
+ - [#12](https://github.com/geekmidas/toolbox/pull/12) [`d53863a`](https://github.com/geekmidas/toolbox/commit/d53863a84db2e4ab5420e08f79128b637043fc42) Thanks [@geekmidas](https://github.com/geekmidas)! - Align every published package on a single version and keep them in step.
17
+
18
+ All packages now share one version, enforced by a changesets `fixed` group. The
19
+ baseline is 9.0.1 — @geekmidas/client's published version — so nothing moves
20
+ backwards; this release takes the whole set to 9.0.2 together.
21
+
22
+ Independent versions made "which version of the docs applies to me"
23
+ unanswerable: a reader on constructs@7 and cli@2 was on no version at all. One
24
+ number per release makes versioned documentation possible, and lets 9 freeze as
25
+ the current paradigm while the constructs rework is developed against it.
26
+
27
+ Every release now publishes every package, and a major anywhere is a major
28
+ everywhere. Peer ranges get simpler in return.
29
+
30
+ - Updated dependencies [[`d53863a`](https://github.com/geekmidas/toolbox/commit/d53863a84db2e4ab5420e08f79128b637043fc42)]:
31
+ - @geekmidas/cache@9.0.2
32
+ - @geekmidas/logger@9.0.2
33
+ - @geekmidas/services@9.0.2
34
+
35
+ ## 4.0.1
36
+
37
+ ### Patch Changes
38
+
39
+ - 🐛 [#11](https://github.com/geekmidas/toolbox/pull/11) [`40f4dc0`](https://github.com/geekmidas/toolbox/commit/40f4dc095911b2223a255029d8f776caf7781309) Thanks [@geekmidas](https://github.com/geekmidas)! - Patch release across all packages to realign published versions with the
40
+ registry. The previous release only published the four packages that had
41
+ version bumps; the remaining packages failed with "cannot publish over the
42
+ previously published versions" because their versions were unchanged.
43
+ - Updated dependencies [[`40f4dc0`](https://github.com/geekmidas/toolbox/commit/40f4dc095911b2223a255029d8f776caf7781309)]:
44
+ - @geekmidas/cache@1.1.2
45
+ - @geekmidas/logger@1.0.3
46
+ - @geekmidas/services@2.0.1
47
+
48
+ ## 4.0.0
49
+
50
+ ### Patch Changes
51
+
52
+ - Updated dependencies []:
53
+ - @geekmidas/services@2.0.0
54
+
55
+ ## 3.0.0
56
+
57
+ ### Patch Changes
58
+
59
+ - Updated dependencies [[`811d740`](https://github.com/geekmidas/toolbox/commit/811d740ae3875d59ad1b0dc50261266963c8cb76)]:
60
+ - @geekmidas/services@1.1.0
61
+
62
+ ## 2.0.1
63
+
64
+ ### Patch Changes
65
+
66
+ - 🐛 [`d70c6c0`](https://github.com/geekmidas/toolbox/commit/d70c6c0aeb8a79da2473ac77dbd8255a4a2f5651) Thanks [@geekmidas](https://github.com/geekmidas)! - Fix `package.json` exports so TypeScript declarations resolve correctly under NodeNext/Bundler module resolution. Each subpath export now nests `types` inside its `import`/`require` condition, pointing at the `.d.mts` and `.d.cts` files that `tsdown` actually emits (previously the exports referenced non-existent `.d.ts` files, causing type-resolution failures for consumers). Both ESM (`.mjs`) and CJS (`.cjs`) runtime entry points are preserved. Additionally, `@geekmidas/ui` had `import` paths pointing at `.js` files that were never emitted — those are corrected to `.mjs`.
67
+
68
+ - Updated dependencies [[`d70c6c0`](https://github.com/geekmidas/toolbox/commit/d70c6c0aeb8a79da2473ac77dbd8255a4a2f5651)]:
69
+ - @geekmidas/cache@1.1.1
70
+ - @geekmidas/logger@1.0.2
71
+ - @geekmidas/services@1.0.2
72
+
73
+ ## 2.0.0
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [[`be4f7a9`](https://github.com/geekmidas/toolbox/commit/be4f7a9bd5de7f08adbca582916d6902e0c24de2)]:
78
+ - @geekmidas/cache@1.1.0
79
+
80
+ ## 1.0.0
81
+
82
+ ### Major Changes
83
+
84
+ - [`ff7b115`](https://github.com/geekmidas/toolbox/commit/ff7b11599f60f84ac6cdc73714c853ecf786b2e8) Thanks [@geekmidas](https://github.com/geekmidas)! - Version 1 Stable release
85
+
86
+ ### Patch Changes
87
+
88
+ - Updated dependencies [[`ff7b115`](https://github.com/geekmidas/toolbox/commit/ff7b11599f60f84ac6cdc73714c853ecf786b2e8)]:
89
+ - @geekmidas/cache@1.0.0
90
+ - @geekmidas/logger@1.0.0
91
+ - @geekmidas/services@1.0.0
package/README.md CHANGED
@@ -58,11 +58,11 @@ try {
58
58
  Rate limiting is built into the constructs package:
59
59
 
60
60
  ```typescript
61
- import { api } from '../constructs/api';
61
+ import { e } from '@geekmidas/constructs/endpoints';
62
62
  import { InMemoryCache } from '@geekmidas/cache/memory';
63
63
  import { z } from 'zod';
64
64
 
65
- export const sendMessage = api
65
+ export const sendMessage = e
66
66
  .post('/api/messages')
67
67
  .rateLimit({
68
68
  limit: 10, // 10 requests
@@ -347,7 +347,7 @@ try {
347
347
 
348
348
  ```typescript
349
349
  // Strict limit for auth endpoints
350
- export const login = api
350
+ export const login = e
351
351
  .post('/auth/login')
352
352
  .rateLimit({
353
353
  limit: 5,
@@ -360,7 +360,7 @@ export const login = api
360
360
  });
361
361
 
362
362
  // Generous limit for read endpoints
363
- export const getUsers = api
363
+ export const getUsers = e
364
364
  .get('/users')
365
365
  .rateLimit({
366
366
  limit: 1000,
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
1
+
2
2
  //#region src/index.ts
3
3
  /**
4
4
  * Error thrown when rate limit is exceeded
@@ -30,13 +30,15 @@ async function checkRateLimit(config, ctx) {
30
30
  resetTime: Date.now() + config.windowMs,
31
31
  retryAfter: config.windowMs
32
32
  };
33
- const key = await (config.keyGenerator || defaultKeyGenerator)(ctx);
33
+ const keyGenerator = config.keyGenerator || defaultKeyGenerator;
34
+ const key = await keyGenerator(ctx);
34
35
  const now = Date.now();
35
36
  let data = await config.cache.get(key);
36
37
  if (!data || data.resetTime <= now) {
38
+ const resetTime = now + config.windowMs;
37
39
  data = {
38
40
  count: 1,
39
- resetTime: now + config.windowMs
41
+ resetTime
40
42
  };
41
43
  const ttlSeconds = Math.ceil(config.windowMs / 1e3);
42
44
  await config.cache.set(key, data, ttlSeconds);
@@ -77,10 +79,10 @@ function getRateLimitHeaders(info, config) {
77
79
  if (info.remaining === 0) headers["Retry-After"] = Math.ceil(info.retryAfter / 1e3).toString();
78
80
  return headers;
79
81
  }
82
+
80
83
  //#endregion
81
84
  exports.TooManyRequestsError = TooManyRequestsError;
82
85
  exports.checkRateLimit = checkRateLimit;
83
86
  exports.defaultKeyGenerator = defaultKeyGenerator;
84
87
  exports.getRateLimitHeaders = getRateLimitHeaders;
85
-
86
88
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class TooManyRequestsError extends Error {\n\tpublic readonly statusCode = 429;\n\tpublic readonly retryAfter?: number;\n\n\tconstructor(message?: string, retryAfter?: number) {\n\t\tsuper(message || 'Too many requests, please try again later.');\n\t\tthis.name = 'TooManyRequestsError';\n\t\tthis.retryAfter = retryAfter;\n\t}\n}\n\n/**\n * Rate limit configuration for an endpoint\n */\nexport interface RateLimitConfig {\n\t/**\n\t * Maximum number of requests allowed in the window\n\t */\n\tlimit: number;\n\n\t/**\n\t * Time window in milliseconds\n\t */\n\twindowMs: number;\n\n\t/**\n\t * Cache instance to store rate limit data\n\t */\n\tcache: Cache;\n\n\t/**\n\t * Key generator function to identify clients\n\t * Defaults to using IP address\n\t */\n\tkeyGenerator?: RateLimitKeyGenerator;\n\n\t/**\n\t * Skip rate limiting for certain requests\n\t */\n\tskip?: RateLimitSkipFn;\n\n\t/**\n\t * Optional message to return when rate limit is exceeded\n\t */\n\tmessage?: string;\n\n\t/**\n\t * Optional custom handler when rate limit is exceeded\n\t */\n\thandler?: RateLimitExceededHandler;\n\n\t/**\n\t * Whether to include rate limit headers in response\n\t * @default true\n\t */\n\tstandardHeaders?: boolean;\n\n\t/**\n\t * Whether to include legacy rate limit headers\n\t * @default false\n\t */\n\tlegacyHeaders?: boolean;\n}\n\n/**\n * Context for rate limiting decisions\n */\nexport interface RateLimitContext<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> {\n\theader: (key: string) => string | undefined;\n\tservices: ServiceRecord<TServices>;\n\tlogger: TLogger;\n\tsession: TSession;\n\tpath: string;\n\tmethod: string;\n}\n\n/**\n * Function to generate a unique key for rate limiting\n */\nexport type RateLimitKeyGenerator<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => string | Promise<string>;\n\n/**\n * Function to determine if rate limiting should be skipped\n */\nexport type RateLimitSkipFn<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => boolean | Promise<boolean>;\n\n/**\n * Handler for when rate limit is exceeded\n */\nexport type RateLimitExceededHandler<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n\tinfo: RateLimitInfo,\n) => void | Promise<void>;\n\n/**\n * Information about current rate limit status\n */\nexport interface RateLimitInfo {\n\t/**\n\t * Current request count in the window\n\t */\n\tcount: number;\n\n\t/**\n\t * Maximum allowed requests\n\t */\n\tlimit: number;\n\n\t/**\n\t * Remaining requests allowed\n\t */\n\tremaining: number;\n\n\t/**\n\t * Time when the window resets (Unix timestamp)\n\t */\n\tresetTime: number;\n\n\t/**\n\t * Time until reset in milliseconds\n\t */\n\tretryAfter: number;\n}\n\n/**\n * Headers to be set on responses\n */\nexport interface RateLimitHeaders {\n\t'X-RateLimit-Limit'?: string;\n\t'X-RateLimit-Remaining'?: string;\n\t'X-RateLimit-Reset'?: string;\n\t'Retry-After'?: string;\n\t'X-RateLimit-Retry-After'?: string;\n\t'X-RateLimit-Reset-After'?: string;\n}\n\n/**\n * Data stored in cache for rate limiting\n */\nexport interface RateLimitData {\n\tcount: number;\n\tresetTime: number;\n}\n\n/**\n * Default key generator using IP address\n */\nexport const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {\n\t// Try various headers for IP address\n\tconst ip =\n\t\tctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||\n\t\tctx.header('x-real-ip') ||\n\t\tctx.header('x-client-ip') ||\n\t\tctx.header('cf-connecting-ip') ||\n\t\t'unknown';\n\n\treturn `rate-limit:${ctx.method}:${ctx.path}:${ip}`;\n};\n\n/**\n * Check rate limit and throw error if exceeded\n */\nexport async function checkRateLimit<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n>(\n\tconfig: RateLimitConfig,\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n): Promise<RateLimitInfo> {\n\t// Check if we should skip rate limiting\n\tif (config.skip && (await config.skip(ctx))) {\n\t\treturn {\n\t\t\tcount: 0,\n\t\t\tlimit: config.limit,\n\t\t\tremaining: config.limit,\n\t\t\tresetTime: Date.now() + config.windowMs,\n\t\t\tretryAfter: config.windowMs,\n\t\t};\n\t}\n\n\t// Generate key for this request\n\tconst keyGenerator = config.keyGenerator || defaultKeyGenerator;\n\tconst key = await keyGenerator(ctx);\n\n\t// Get current data from cache\n\tconst now = Date.now();\n\tlet data = await config.cache.get<RateLimitData>(key);\n\n\t// If no data or window expired, create new entry\n\tif (!data || data.resetTime <= now) {\n\t\tconst resetTime = now + config.windowMs;\n\t\tdata = { count: 1, resetTime };\n\n\t\t// Store with TTL matching the window\n\t\tconst ttlSeconds = Math.ceil(config.windowMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t} else {\n\t\t// Increment count\n\t\tdata.count++;\n\n\t\t// Calculate remaining TTL\n\t\tconst remainingMs = data.resetTime - now;\n\t\tconst ttlSeconds = Math.ceil(remainingMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t}\n\n\t// Calculate rate limit info\n\tconst info: RateLimitInfo = {\n\t\tcount: data.count,\n\t\tlimit: config.limit,\n\t\tremaining: Math.max(0, config.limit - data.count),\n\t\tresetTime: data.resetTime,\n\t\tretryAfter: data.resetTime - now,\n\t};\n\n\t// Check if limit exceeded\n\tif (data.count > config.limit) {\n\t\t// Call custom handler if provided\n\t\tif (config.handler) {\n\t\t\tawait config.handler(ctx, info);\n\t\t}\n\n\t\t// Throw rate limit error\n\t\tconst retryAfterSeconds = Math.ceil(info.retryAfter / 1000);\n\t\tthrow new TooManyRequestsError(\n\t\t\tconfig.message || 'Too many requests, please try again later.',\n\t\t\tretryAfterSeconds,\n\t\t);\n\t}\n\n\treturn info;\n}\n\n/**\n * Generate rate limit headers\n */\nexport function getRateLimitHeaders(\n\tinfo: RateLimitInfo,\n\tconfig: RateLimitConfig,\n): RateLimitHeaders {\n\tconst headers: RateLimitHeaders = {};\n\n\tif (config.standardHeaders !== false) {\n\t\theaders['X-RateLimit-Limit'] = info.limit.toString();\n\t\theaders['X-RateLimit-Remaining'] = info.remaining.toString();\n\t\theaders['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();\n\t}\n\n\tif (config.legacyHeaders) {\n\t\theaders['X-RateLimit-Retry-After'] = info.retryAfter.toString();\n\t\theaders['X-RateLimit-Reset-After'] = Math.ceil(\n\t\t\tinfo.retryAfter / 1000,\n\t\t).toString();\n\t}\n\n\t// Always set Retry-After when limit is exceeded\n\tif (info.remaining === 0) {\n\t\theaders['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();\n\t}\n\n\treturn headers;\n}\n"],"mappings":";;;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;CAC/C,aAA6B;CAC7B;CAEA,YAAY,SAAkB,YAAqB;EAClD,MAAM,WAAW,4CAA4C;EAC7D,KAAK,OAAO;EACZ,KAAK,aAAa;CACnB;AACD;;;;AA8JA,MAAa,uBAA8C,QAAQ;CAElE,MAAM,KACL,IAAI,OAAO,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KACnD,IAAI,OAAO,WAAW,KACtB,IAAI,OAAO,aAAa,KACxB,IAAI,OAAO,kBAAkB,KAC7B;CAED,OAAO,cAAc,IAAI,OAAO,GAAG,IAAI,KAAK,GAAG;AAChD;;;;AAKA,eAAsB,eAKrB,QACA,KACyB;CAEzB,IAAI,OAAO,QAAS,MAAM,OAAO,KAAK,GAAG,GACxC,OAAO;EACN,OAAO;EACP,OAAO,OAAO;EACd,WAAW,OAAO;EAClB,WAAW,KAAK,IAAI,IAAI,OAAO;EAC/B,YAAY,OAAO;CACpB;CAKD,MAAM,MAAM,OADS,OAAO,gBAAgB,oBAAA,CACb,GAAG;CAGlC,MAAM,MAAM,KAAK,IAAI;CACrB,IAAI,OAAO,MAAM,OAAO,MAAM,IAAmB,GAAG;CAGpD,IAAI,CAAC,QAAQ,KAAK,aAAa,KAAK;EAEnC,OAAO;GAAE,OAAO;GAAG,WADD,MAAM,OAAO;EACF;EAG7B,MAAM,aAAa,KAAK,KAAK,OAAO,WAAW,GAAI;EACnD,MAAM,OAAO,MAAM,IAAI,KAAK,MAAM,UAAU;CAC7C,OAAO;EAEN,KAAK;EAGL,MAAM,cAAc,KAAK,YAAY;EACrC,MAAM,aAAa,KAAK,KAAK,cAAc,GAAI;EAC/C,MAAM,OAAO,MAAM,IAAI,KAAK,MAAM,UAAU;CAC7C;CAGA,MAAM,OAAsB;EAC3B,OAAO,KAAK;EACZ,OAAO,OAAO;EACd,WAAW,KAAK,IAAI,GAAG,OAAO,QAAQ,KAAK,KAAK;EAChD,WAAW,KAAK;EAChB,YAAY,KAAK,YAAY;CAC9B;CAGA,IAAI,KAAK,QAAQ,OAAO,OAAO;EAE9B,IAAI,OAAO,SACV,MAAM,OAAO,QAAQ,KAAK,IAAI;EAI/B,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,GAAI;EAC1D,MAAM,IAAI,qBACT,OAAO,WAAW,8CAClB,iBACD;CACD;CAEA,OAAO;AACR;;;;AAKA,SAAgB,oBACf,MACA,QACmB;CACnB,MAAM,UAA4B,CAAC;CAEnC,IAAI,OAAO,oBAAoB,OAAO;EACrC,QAAQ,uBAAuB,KAAK,MAAM,SAAS;EACnD,QAAQ,2BAA2B,KAAK,UAAU,SAAS;EAC3D,QAAQ,uBAAuB,IAAI,KAAK,KAAK,SAAS,CAAC,CAAC,YAAY;CACrE;CAEA,IAAI,OAAO,eAAe;EACzB,QAAQ,6BAA6B,KAAK,WAAW,SAAS;EAC9D,QAAQ,6BAA6B,KAAK,KACzC,KAAK,aAAa,GACnB,CAAC,CAAC,SAAS;CACZ;CAGA,IAAI,KAAK,cAAc,GACtB,QAAQ,iBAAiB,KAAK,KAAK,KAAK,aAAa,GAAI,CAAC,CAAC,SAAS;CAGrE,OAAO;AACR"}
1
+ {"version":3,"file":"index.cjs","names":["message?: string","retryAfter?: number","defaultKeyGenerator: RateLimitKeyGenerator","config: RateLimitConfig","ctx: RateLimitContext<TServices, TLogger, TSession>","info: RateLimitInfo","headers: RateLimitHeaders"],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class TooManyRequestsError extends Error {\n\tpublic readonly statusCode = 429;\n\tpublic readonly retryAfter?: number;\n\n\tconstructor(message?: string, retryAfter?: number) {\n\t\tsuper(message || 'Too many requests, please try again later.');\n\t\tthis.name = 'TooManyRequestsError';\n\t\tthis.retryAfter = retryAfter;\n\t}\n}\n\n/**\n * Rate limit configuration for an endpoint\n */\nexport interface RateLimitConfig {\n\t/**\n\t * Maximum number of requests allowed in the window\n\t */\n\tlimit: number;\n\n\t/**\n\t * Time window in milliseconds\n\t */\n\twindowMs: number;\n\n\t/**\n\t * Cache instance to store rate limit data\n\t */\n\tcache: Cache;\n\n\t/**\n\t * Key generator function to identify clients\n\t * Defaults to using IP address\n\t */\n\tkeyGenerator?: RateLimitKeyGenerator;\n\n\t/**\n\t * Skip rate limiting for certain requests\n\t */\n\tskip?: RateLimitSkipFn;\n\n\t/**\n\t * Optional message to return when rate limit is exceeded\n\t */\n\tmessage?: string;\n\n\t/**\n\t * Optional custom handler when rate limit is exceeded\n\t */\n\thandler?: RateLimitExceededHandler;\n\n\t/**\n\t * Whether to include rate limit headers in response\n\t * @default true\n\t */\n\tstandardHeaders?: boolean;\n\n\t/**\n\t * Whether to include legacy rate limit headers\n\t * @default false\n\t */\n\tlegacyHeaders?: boolean;\n}\n\n/**\n * Context for rate limiting decisions\n */\nexport interface RateLimitContext<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> {\n\theader: (key: string) => string | undefined;\n\tservices: ServiceRecord<TServices>;\n\tlogger: TLogger;\n\tsession: TSession;\n\tpath: string;\n\tmethod: string;\n}\n\n/**\n * Function to generate a unique key for rate limiting\n */\nexport type RateLimitKeyGenerator<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => string | Promise<string>;\n\n/**\n * Function to determine if rate limiting should be skipped\n */\nexport type RateLimitSkipFn<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => boolean | Promise<boolean>;\n\n/**\n * Handler for when rate limit is exceeded\n */\nexport type RateLimitExceededHandler<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n\tinfo: RateLimitInfo,\n) => void | Promise<void>;\n\n/**\n * Information about current rate limit status\n */\nexport interface RateLimitInfo {\n\t/**\n\t * Current request count in the window\n\t */\n\tcount: number;\n\n\t/**\n\t * Maximum allowed requests\n\t */\n\tlimit: number;\n\n\t/**\n\t * Remaining requests allowed\n\t */\n\tremaining: number;\n\n\t/**\n\t * Time when the window resets (Unix timestamp)\n\t */\n\tresetTime: number;\n\n\t/**\n\t * Time until reset in milliseconds\n\t */\n\tretryAfter: number;\n}\n\n/**\n * Headers to be set on responses\n */\nexport interface RateLimitHeaders {\n\t'X-RateLimit-Limit'?: string;\n\t'X-RateLimit-Remaining'?: string;\n\t'X-RateLimit-Reset'?: string;\n\t'Retry-After'?: string;\n\t'X-RateLimit-Retry-After'?: string;\n\t'X-RateLimit-Reset-After'?: string;\n}\n\n/**\n * Data stored in cache for rate limiting\n */\nexport interface RateLimitData {\n\tcount: number;\n\tresetTime: number;\n}\n\n/**\n * Default key generator using IP address\n */\nexport const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {\n\t// Try various headers for IP address\n\tconst ip =\n\t\tctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||\n\t\tctx.header('x-real-ip') ||\n\t\tctx.header('x-client-ip') ||\n\t\tctx.header('cf-connecting-ip') ||\n\t\t'unknown';\n\n\treturn `rate-limit:${ctx.method}:${ctx.path}:${ip}`;\n};\n\n/**\n * Check rate limit and throw error if exceeded\n */\nexport async function checkRateLimit<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n>(\n\tconfig: RateLimitConfig,\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n): Promise<RateLimitInfo> {\n\t// Check if we should skip rate limiting\n\tif (config.skip && (await config.skip(ctx))) {\n\t\treturn {\n\t\t\tcount: 0,\n\t\t\tlimit: config.limit,\n\t\t\tremaining: config.limit,\n\t\t\tresetTime: Date.now() + config.windowMs,\n\t\t\tretryAfter: config.windowMs,\n\t\t};\n\t}\n\n\t// Generate key for this request\n\tconst keyGenerator = config.keyGenerator || defaultKeyGenerator;\n\tconst key = await keyGenerator(ctx);\n\n\t// Get current data from cache\n\tconst now = Date.now();\n\tlet data = await config.cache.get<RateLimitData>(key);\n\n\t// If no data or window expired, create new entry\n\tif (!data || data.resetTime <= now) {\n\t\tconst resetTime = now + config.windowMs;\n\t\tdata = { count: 1, resetTime };\n\n\t\t// Store with TTL matching the window\n\t\tconst ttlSeconds = Math.ceil(config.windowMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t} else {\n\t\t// Increment count\n\t\tdata.count++;\n\n\t\t// Calculate remaining TTL\n\t\tconst remainingMs = data.resetTime - now;\n\t\tconst ttlSeconds = Math.ceil(remainingMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t}\n\n\t// Calculate rate limit info\n\tconst info: RateLimitInfo = {\n\t\tcount: data.count,\n\t\tlimit: config.limit,\n\t\tremaining: Math.max(0, config.limit - data.count),\n\t\tresetTime: data.resetTime,\n\t\tretryAfter: data.resetTime - now,\n\t};\n\n\t// Check if limit exceeded\n\tif (data.count > config.limit) {\n\t\t// Call custom handler if provided\n\t\tif (config.handler) {\n\t\t\tawait config.handler(ctx, info);\n\t\t}\n\n\t\t// Throw rate limit error\n\t\tconst retryAfterSeconds = Math.ceil(info.retryAfter / 1000);\n\t\tthrow new TooManyRequestsError(\n\t\t\tconfig.message || 'Too many requests, please try again later.',\n\t\t\tretryAfterSeconds,\n\t\t);\n\t}\n\n\treturn info;\n}\n\n/**\n * Generate rate limit headers\n */\nexport function getRateLimitHeaders(\n\tinfo: RateLimitInfo,\n\tconfig: RateLimitConfig,\n): RateLimitHeaders {\n\tconst headers: RateLimitHeaders = {};\n\n\tif (config.standardHeaders !== false) {\n\t\theaders['X-RateLimit-Limit'] = info.limit.toString();\n\t\theaders['X-RateLimit-Remaining'] = info.remaining.toString();\n\t\theaders['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();\n\t}\n\n\tif (config.legacyHeaders) {\n\t\theaders['X-RateLimit-Retry-After'] = info.retryAfter.toString();\n\t\theaders['X-RateLimit-Reset-After'] = Math.ceil(\n\t\t\tinfo.retryAfter / 1000,\n\t\t).toString();\n\t}\n\n\t// Always set Retry-After when limit is exceeded\n\tif (info.remaining === 0) {\n\t\theaders['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();\n\t}\n\n\treturn headers;\n}\n"],"mappings":";;;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;CAC/C,AAAgB,aAAa;CAC7B,AAAgB;CAEhB,YAAYA,SAAkBC,YAAqB;AAClD,QAAM,WAAW,6CAA6C;AAC9D,OAAK,OAAO;AACZ,OAAK,aAAa;CAClB;AACD;;;;AA8JD,MAAaC,sBAA6C,CAAC,QAAQ;CAElE,MAAM,KACL,IAAI,OAAO,kBAAkB,EAAE,MAAM,IAAI,CAAC,IAAI,MAAM,IACpD,IAAI,OAAO,YAAY,IACvB,IAAI,OAAO,cAAc,IACzB,IAAI,OAAO,mBAAmB,IAC9B;AAED,SAAQ,aAAa,IAAI,OAAO,GAAG,IAAI,KAAK,GAAG,GAAG;AAClD;;;;AAKD,eAAsB,eAKrBC,QACAC,KACyB;AAEzB,KAAI,OAAO,QAAS,MAAM,OAAO,KAAK,IAAI,CACzC,QAAO;EACN,OAAO;EACP,OAAO,OAAO;EACd,WAAW,OAAO;EAClB,WAAW,KAAK,KAAK,GAAG,OAAO;EAC/B,YAAY,OAAO;CACnB;CAIF,MAAM,eAAe,OAAO,gBAAgB;CAC5C,MAAM,MAAM,MAAM,aAAa,IAAI;CAGnC,MAAM,MAAM,KAAK,KAAK;CACtB,IAAI,OAAO,MAAM,OAAO,MAAM,IAAmB,IAAI;AAGrD,MAAK,QAAQ,KAAK,aAAa,KAAK;EACnC,MAAM,YAAY,MAAM,OAAO;AAC/B,SAAO;GAAE,OAAO;GAAG;EAAW;EAG9B,MAAM,aAAa,KAAK,KAAK,OAAO,WAAW,IAAK;AACpD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC7C,OAAM;AAEN,OAAK;EAGL,MAAM,cAAc,KAAK,YAAY;EACrC,MAAM,aAAa,KAAK,KAAK,cAAc,IAAK;AAChD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC7C;CAGD,MAAMC,OAAsB;EAC3B,OAAO,KAAK;EACZ,OAAO,OAAO;EACd,WAAW,KAAK,IAAI,GAAG,OAAO,QAAQ,KAAK,MAAM;EACjD,WAAW,KAAK;EAChB,YAAY,KAAK,YAAY;CAC7B;AAGD,KAAI,KAAK,QAAQ,OAAO,OAAO;AAE9B,MAAI,OAAO,QACV,OAAM,OAAO,QAAQ,KAAK,KAAK;EAIhC,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,IAAK;AAC3D,QAAM,IAAI,qBACT,OAAO,WAAW,8CAClB;CAED;AAED,QAAO;AACP;;;;AAKD,SAAgB,oBACfA,MACAF,QACmB;CACnB,MAAMG,UAA4B,CAAE;AAEpC,KAAI,OAAO,oBAAoB,OAAO;AACrC,UAAQ,uBAAuB,KAAK,MAAM,UAAU;AACpD,UAAQ,2BAA2B,KAAK,UAAU,UAAU;AAC5D,UAAQ,uBAAuB,IAAI,KAAK,KAAK,WAAW,aAAa;CACrE;AAED,KAAI,OAAO,eAAe;AACzB,UAAQ,6BAA6B,KAAK,WAAW,UAAU;AAC/D,UAAQ,6BAA6B,KAAK,KACzC,KAAK,aAAa,IAClB,CAAC,UAAU;CACZ;AAGD,KAAI,KAAK,cAAc,EACtB,SAAQ,iBAAiB,KAAK,KAAK,KAAK,aAAa,IAAK,CAAC,UAAU;AAGtE,QAAO;AACP"}
package/dist/index.d.cts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Cache } from "@geekmidas/cache";
2
2
  import { Logger } from "@geekmidas/logger";
3
3
  import { Service, ServiceRecord } from "@geekmidas/services";
4
+
4
5
  //#region src/index.d.ts
6
+
5
7
  /**
6
8
  * Error thrown when rate limit is exceeded
7
9
  */
8
- export declare class TooManyRequestsError extends Error {
10
+ declare class TooManyRequestsError extends Error {
9
11
  readonly statusCode = 429;
10
12
  readonly retryAfter?: number;
11
13
  constructor(message?: string, retryAfter?: number);
@@ -13,7 +15,7 @@ export declare class TooManyRequestsError extends Error {
13
15
  /**
14
16
  * Rate limit configuration for an endpoint
15
17
  */
16
- export interface RateLimitConfig {
18
+ interface RateLimitConfig {
17
19
  /**
18
20
  * Maximum number of requests allowed in the window
19
21
  */
@@ -57,7 +59,7 @@ export interface RateLimitConfig {
57
59
  /**
58
60
  * Context for rate limiting decisions
59
61
  */
60
- export interface RateLimitContext<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> {
62
+ interface RateLimitContext<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> {
61
63
  header: (key: string) => string | undefined;
62
64
  services: ServiceRecord<TServices>;
63
65
  logger: TLogger;
@@ -68,19 +70,19 @@ export interface RateLimitContext<TServices extends Service[] = [], TLogger exte
68
70
  /**
69
71
  * Function to generate a unique key for rate limiting
70
72
  */
71
- export type RateLimitKeyGenerator<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => string | Promise<string>;
73
+ type RateLimitKeyGenerator<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => string | Promise<string>;
72
74
  /**
73
75
  * Function to determine if rate limiting should be skipped
74
76
  */
75
- export type RateLimitSkipFn<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => boolean | Promise<boolean>;
77
+ type RateLimitSkipFn<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => boolean | Promise<boolean>;
76
78
  /**
77
79
  * Handler for when rate limit is exceeded
78
80
  */
79
- export type RateLimitExceededHandler<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>, info: RateLimitInfo) => void | Promise<void>;
81
+ type RateLimitExceededHandler<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>, info: RateLimitInfo) => void | Promise<void>;
80
82
  /**
81
83
  * Information about current rate limit status
82
84
  */
83
- export interface RateLimitInfo {
85
+ interface RateLimitInfo {
84
86
  /**
85
87
  * Current request count in the window
86
88
  */
@@ -105,7 +107,7 @@ export interface RateLimitInfo {
105
107
  /**
106
108
  * Headers to be set on responses
107
109
  */
108
- export interface RateLimitHeaders {
110
+ interface RateLimitHeaders {
109
111
  'X-RateLimit-Limit'?: string;
110
112
  'X-RateLimit-Remaining'?: string;
111
113
  'X-RateLimit-Reset'?: string;
@@ -116,21 +118,23 @@ export interface RateLimitHeaders {
116
118
  /**
117
119
  * Data stored in cache for rate limiting
118
120
  */
119
- export interface RateLimitData {
121
+ interface RateLimitData {
120
122
  count: number;
121
123
  resetTime: number;
122
124
  }
123
125
  /**
124
126
  * Default key generator using IP address
125
127
  */
126
- export declare const defaultKeyGenerator: RateLimitKeyGenerator;
128
+ declare const defaultKeyGenerator: RateLimitKeyGenerator;
127
129
  /**
128
130
  * Check rate limit and throw error if exceeded
129
131
  */
130
- export declare function checkRateLimit<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown>(config: RateLimitConfig, ctx: RateLimitContext<TServices, TLogger, TSession>): Promise<RateLimitInfo>;
132
+ declare function checkRateLimit<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown>(config: RateLimitConfig, ctx: RateLimitContext<TServices, TLogger, TSession>): Promise<RateLimitInfo>;
131
133
  /**
132
134
  * Generate rate limit headers
133
135
  */
134
- export declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
136
+ declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
137
+ //# sourceMappingURL=index.d.ts.map
135
138
  //#endregion
139
+ export { RateLimitConfig, RateLimitContext, RateLimitData, RateLimitExceededHandler, RateLimitHeaders, RateLimitInfo, RateLimitKeyGenerator, RateLimitSkipFn, TooManyRequestsError, checkRateLimit, defaultKeyGenerator, getRateLimitHeaders };
136
140
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;qBAOa,6BAA6B;WACzB;WACA;EAEhB,YAAY,kBAAkB;;;;;iBAUd;;;;EAIhB;;;;EAKA;;;;EAKA,OAAO;;;;;EAMP,eAAe;;;;EAKf,OAAO;;;;EAKP;;;;EAKA,UAAU;;;;;EAMV;;;;;EAMA;;;;;iBAMgB,iBAChB,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB;EAEA,SAAS;EACT,UAAU,cAAc;EACxB,QAAQ;EACR,SAAS;EACT;EACA;;;;;YAMW,sBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,uBAC7B;;;;YAKF,gBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,wBAC5B;;;;YAKH,yBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,WAC1C,MAAM,yBACK;;;;iBAKK;;;;EAIhB;;;;EAKA;;;;EAKA;;;;EAKA;;;;EAKA;;;;;iBAMgB;EAChB;EACA;EACA;EACA;EACA;EACA;;;;;iBAMgB;EAChB;EACA;;;;;qBAMY,qBAAqB;;;;wBAeZ,eACrB,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,oBAEA,QAAQ,iBACR,KAAK,iBAAiB,WAAW,SAAS,YACxC,QAAQ;;;;wBAoEK,oBACf,MAAM,eACN,QAAQ,kBACN"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;;AAOA;AAciB,cAdJ,oBAAA,SAA6B,KAAK,CAcf;EAAA,SAAA,UAAA,GAAA,GAAA;EAAA,SAcxB,UAAA,CAAA,EAAA,MAAA;EAAK,WAMG,CAAA,OAAA,CAAA,EAAA,MAAA,EAAA,UAAA,CAAA,EAAA,MAAA;;;AAemB;AAkBnC;AAAiC,UArDhB,eAAA,CAqDgB;EAAA;;;EAED,KAIP,EAAA,MAAA;EAAS;;;EAEhB,QAAA,EAAA,MAAA;EAQN;;;EACc,KACT,EAzDT,KAyDS;EAAM;;;;EAG4B,YAA7C,CAAA,EAtDU,qBAsDV;EAAgB;AACD;AAKrB;EAA2B,IAAA,CAAA,EAvDnB,eAuDmB;EAAA;;;EAEK,OAGT,CAAA,EAAA,MAAA;EAAS;;;EAAV,OACP,CAAA,EAnDJ,wBAmDI;EAAO;AAKtB;;;EAC0B,eACT,CAAA,EAAA,OAAA;EAAM;;;;EAG4B,aAA7C,CAAA,EAAA,OAAA;;;AAEa;AAKnB;AA8BiB,UAhFA,gBAgFgB,CAAA,kBA/Ed,OA+Ec,EAAA,GAAA,EAAA,EAAA,gBA9EhB,MA8EgB,GA9EP,MA8EO,EAAA,WAAA,OAAA,CAAA,CAAA;EAYhB,MAAA,EAAA,CAAA,GAAA,EAAA,MAAa,EAAA,GAAA,MAAA,GAAA,SAAA;EAQjB,QAAA,EA9FF,aAwGV,CAxGwB,SA8FS,CAAA;EAeZ,MAAA,EA5Gb,OA4Ga;EAAc,OAAA,EA3G1B,QA2G0B;EAAA,IACjB,EAAA,MAAA;EAAO,MACT,EAAA,MAAA;;;;;AAI0B,KAzG/B,qBAyG+B,CAAA,kBAxGxB,OAwGwB,EAAA,GAAA,EAAA,EAAA,gBAvG1B,MAuG0B,GAvGjB,MAuGiB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EApGrC,gBAoGqC,CApGpB,SAoGoB,EApGT,OAoGS,EApGA,QAoGA,CAAA,EAAA,GAAA,MAAA,GAnG7B,OAmG6B,CAAA,MAAA,CAAA;;;;AACjC,KA/FE,eA+FF,CAAA,kBA9FS,OA8FT,EAAA,GAAA,EAAA,EAAA,gBA7FO,MA6FP,GA7FgB,MA6FhB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EA1FJ,gBA0FI,CA1Fa,SA0Fb,EA1FwB,OA0FxB,EA1FiC,QA0FjC,CAAA,EAAA,GAAA,OAAA,GAzFK,OAyFL,CAAA,OAAA,CAAA;AAoEV;;;AAES,KA1JG,wBA0JH,CAAA,kBAzJU,OAyJV,EAAA,GAAA,EAAA,EAAA,gBAxJQ,MAwJR,GAxJiB,MAwJjB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EArJH,gBAqJG,CArJc,SAqJd,EArJyB,OAqJzB,EArJkC,QAqJlC,CAAA,EAAA,IAAA,EApJF,aAoJE,EAAA,GAAA,IAAA,GAnJG,OAmJH,CAAA,IAAA,CAAA;;AACU;;UA/IF,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;UA8BA,gBAAA;;;;;;;;;;;UAYA,aAAA;;;;;;;cAQJ,qBAAqB;;;;iBAeZ,iCACH,gCACF,SAAS,oCAGjB,sBACH,iBAAiB,WAAW,SAAS,YACxC,QAAQ;;;;iBAoEK,mBAAA,OACT,uBACE,kBACN"}
package/dist/index.d.mts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Cache } from "@geekmidas/cache";
2
2
  import { Logger } from "@geekmidas/logger";
3
3
  import { Service, ServiceRecord } from "@geekmidas/services";
4
+
4
5
  //#region src/index.d.ts
6
+
5
7
  /**
6
8
  * Error thrown when rate limit is exceeded
7
9
  */
8
- export declare class TooManyRequestsError extends Error {
10
+ declare class TooManyRequestsError extends Error {
9
11
  readonly statusCode = 429;
10
12
  readonly retryAfter?: number;
11
13
  constructor(message?: string, retryAfter?: number);
@@ -13,7 +15,7 @@ export declare class TooManyRequestsError extends Error {
13
15
  /**
14
16
  * Rate limit configuration for an endpoint
15
17
  */
16
- export interface RateLimitConfig {
18
+ interface RateLimitConfig {
17
19
  /**
18
20
  * Maximum number of requests allowed in the window
19
21
  */
@@ -57,7 +59,7 @@ export interface RateLimitConfig {
57
59
  /**
58
60
  * Context for rate limiting decisions
59
61
  */
60
- export interface RateLimitContext<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> {
62
+ interface RateLimitContext<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> {
61
63
  header: (key: string) => string | undefined;
62
64
  services: ServiceRecord<TServices>;
63
65
  logger: TLogger;
@@ -68,19 +70,19 @@ export interface RateLimitContext<TServices extends Service[] = [], TLogger exte
68
70
  /**
69
71
  * Function to generate a unique key for rate limiting
70
72
  */
71
- export type RateLimitKeyGenerator<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => string | Promise<string>;
73
+ type RateLimitKeyGenerator<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => string | Promise<string>;
72
74
  /**
73
75
  * Function to determine if rate limiting should be skipped
74
76
  */
75
- export type RateLimitSkipFn<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => boolean | Promise<boolean>;
77
+ type RateLimitSkipFn<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>) => boolean | Promise<boolean>;
76
78
  /**
77
79
  * Handler for when rate limit is exceeded
78
80
  */
79
- export type RateLimitExceededHandler<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>, info: RateLimitInfo) => void | Promise<void>;
81
+ type RateLimitExceededHandler<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown> = (ctx: RateLimitContext<TServices, TLogger, TSession>, info: RateLimitInfo) => void | Promise<void>;
80
82
  /**
81
83
  * Information about current rate limit status
82
84
  */
83
- export interface RateLimitInfo {
85
+ interface RateLimitInfo {
84
86
  /**
85
87
  * Current request count in the window
86
88
  */
@@ -105,7 +107,7 @@ export interface RateLimitInfo {
105
107
  /**
106
108
  * Headers to be set on responses
107
109
  */
108
- export interface RateLimitHeaders {
110
+ interface RateLimitHeaders {
109
111
  'X-RateLimit-Limit'?: string;
110
112
  'X-RateLimit-Remaining'?: string;
111
113
  'X-RateLimit-Reset'?: string;
@@ -116,21 +118,23 @@ export interface RateLimitHeaders {
116
118
  /**
117
119
  * Data stored in cache for rate limiting
118
120
  */
119
- export interface RateLimitData {
121
+ interface RateLimitData {
120
122
  count: number;
121
123
  resetTime: number;
122
124
  }
123
125
  /**
124
126
  * Default key generator using IP address
125
127
  */
126
- export declare const defaultKeyGenerator: RateLimitKeyGenerator;
128
+ declare const defaultKeyGenerator: RateLimitKeyGenerator;
127
129
  /**
128
130
  * Check rate limit and throw error if exceeded
129
131
  */
130
- export declare function checkRateLimit<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown>(config: RateLimitConfig, ctx: RateLimitContext<TServices, TLogger, TSession>): Promise<RateLimitInfo>;
132
+ declare function checkRateLimit<TServices extends Service[] = [], TLogger extends Logger = Logger, TSession = unknown>(config: RateLimitConfig, ctx: RateLimitContext<TServices, TLogger, TSession>): Promise<RateLimitInfo>;
131
133
  /**
132
134
  * Generate rate limit headers
133
135
  */
134
- export declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
136
+ declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
137
+ //# sourceMappingURL=index.d.ts.map
135
138
  //#endregion
139
+ export { RateLimitConfig, RateLimitContext, RateLimitData, RateLimitExceededHandler, RateLimitHeaders, RateLimitInfo, RateLimitKeyGenerator, RateLimitSkipFn, TooManyRequestsError, checkRateLimit, defaultKeyGenerator, getRateLimitHeaders };
136
140
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;qBAOa,6BAA6B;WACzB;WACA;EAEhB,YAAY,kBAAkB;;;;;iBAUd;;;;EAIhB;;;;EAKA;;;;EAKA,OAAO;;;;;EAMP,eAAe;;;;EAKf,OAAO;;;;EAKP;;;;EAKA,UAAU;;;;;EAMV;;;;;EAMA;;;;;iBAMgB,iBAChB,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB;EAEA,SAAS;EACT,UAAU,cAAc;EACxB,QAAQ;EACR,SAAS;EACT;EACA;;;;;YAMW,sBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,uBAC7B;;;;YAKF,gBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,wBAC5B;;;;YAKH,yBACX,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,uBAEA,KAAK,iBAAiB,WAAW,SAAS,WAC1C,MAAM,yBACK;;;;iBAKK;;;;EAIhB;;;;EAKA;;;;EAKA;;;;EAKA;;;;EAKA;;;;;iBAMgB;EAChB;EACA;EACA;EACA;EACA;EACA;;;;;iBAMgB;EAChB;EACA;;;;;qBAMY,qBAAqB;;;;wBAeZ,eACrB,kBAAkB,gBAClB,gBAAgB,SAAS,QACzB,oBAEA,QAAQ,iBACR,KAAK,iBAAiB,WAAW,SAAS,YACxC,QAAQ;;;;wBAoEK,oBACf,MAAM,eACN,QAAQ,kBACN"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;;AAOA;AAciB,cAdJ,oBAAA,SAA6B,KAAK,CAcf;EAAA,SAAA,UAAA,GAAA,GAAA;EAAA,SAcxB,UAAA,CAAA,EAAA,MAAA;EAAK,WAMG,CAAA,OAAA,CAAA,EAAA,MAAA,EAAA,UAAA,CAAA,EAAA,MAAA;;;AAemB;AAkBnC;AAAiC,UArDhB,eAAA,CAqDgB;EAAA;;;EAED,KAIP,EAAA,MAAA;EAAS;;;EAEhB,QAAA,EAAA,MAAA;EAQN;;;EACc,KACT,EAzDT,KAyDS;EAAM;;;;EAG4B,YAA7C,CAAA,EAtDU,qBAsDV;EAAgB;AACD;AAKrB;EAA2B,IAAA,CAAA,EAvDnB,eAuDmB;EAAA;;;EAEK,OAGT,CAAA,EAAA,MAAA;EAAS;;;EAAV,OACP,CAAA,EAnDJ,wBAmDI;EAAO;AAKtB;;;EAC0B,eACT,CAAA,EAAA,OAAA;EAAM;;;;EAG4B,aAA7C,CAAA,EAAA,OAAA;;;AAEa;AAKnB;AA8BiB,UAhFA,gBAgFgB,CAAA,kBA/Ed,OA+Ec,EAAA,GAAA,EAAA,EAAA,gBA9EhB,MA8EgB,GA9EP,MA8EO,EAAA,WAAA,OAAA,CAAA,CAAA;EAYhB,MAAA,EAAA,CAAA,GAAA,EAAA,MAAa,EAAA,GAAA,MAAA,GAAA,SAAA;EAQjB,QAAA,EA9FF,aAwGV,CAxGwB,SA8FS,CAAA;EAeZ,MAAA,EA5Gb,OA4Ga;EAAc,OAAA,EA3G1B,QA2G0B;EAAA,IACjB,EAAA,MAAA;EAAO,MACT,EAAA,MAAA;;;;;AAI0B,KAzG/B,qBAyG+B,CAAA,kBAxGxB,OAwGwB,EAAA,GAAA,EAAA,EAAA,gBAvG1B,MAuG0B,GAvGjB,MAuGiB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EApGrC,gBAoGqC,CApGpB,SAoGoB,EApGT,OAoGS,EApGA,QAoGA,CAAA,EAAA,GAAA,MAAA,GAnG7B,OAmG6B,CAAA,MAAA,CAAA;;;;AACjC,KA/FE,eA+FF,CAAA,kBA9FS,OA8FT,EAAA,GAAA,EAAA,EAAA,gBA7FO,MA6FP,GA7FgB,MA6FhB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EA1FJ,gBA0FI,CA1Fa,SA0Fb,EA1FwB,OA0FxB,EA1FiC,QA0FjC,CAAA,EAAA,GAAA,OAAA,GAzFK,OAyFL,CAAA,OAAA,CAAA;AAoEV;;;AAES,KA1JG,wBA0JH,CAAA,kBAzJU,OAyJV,EAAA,GAAA,EAAA,EAAA,gBAxJQ,MAwJR,GAxJiB,MAwJjB,EAAA,WAAA,OAAA,CAAA,GAAA,CAAA,GAAA,EArJH,gBAqJG,CArJc,SAqJd,EArJyB,OAqJzB,EArJkC,QAqJlC,CAAA,EAAA,IAAA,EApJF,aAoJE,EAAA,GAAA,IAAA,GAnJG,OAmJH,CAAA,IAAA,CAAA;;AACU;;UA/IF,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;UA8BA,gBAAA;;;;;;;;;;;UAYA,aAAA;;;;;;;cAQJ,qBAAqB;;;;iBAeZ,iCACH,gCACF,SAAS,oCAGjB,sBACH,iBAAiB,WAAW,SAAS,YACxC,QAAQ;;;;iBAoEK,mBAAA,OACT,uBACE,kBACN"}
package/dist/index.mjs CHANGED
@@ -29,13 +29,15 @@ async function checkRateLimit(config, ctx) {
29
29
  resetTime: Date.now() + config.windowMs,
30
30
  retryAfter: config.windowMs
31
31
  };
32
- const key = await (config.keyGenerator || defaultKeyGenerator)(ctx);
32
+ const keyGenerator = config.keyGenerator || defaultKeyGenerator;
33
+ const key = await keyGenerator(ctx);
33
34
  const now = Date.now();
34
35
  let data = await config.cache.get(key);
35
36
  if (!data || data.resetTime <= now) {
37
+ const resetTime = now + config.windowMs;
36
38
  data = {
37
39
  count: 1,
38
- resetTime: now + config.windowMs
40
+ resetTime
39
41
  };
40
42
  const ttlSeconds = Math.ceil(config.windowMs / 1e3);
41
43
  await config.cache.set(key, data, ttlSeconds);
@@ -76,7 +78,7 @@ function getRateLimitHeaders(info, config) {
76
78
  if (info.remaining === 0) headers["Retry-After"] = Math.ceil(info.retryAfter / 1e3).toString();
77
79
  return headers;
78
80
  }
81
+
79
82
  //#endregion
80
83
  export { TooManyRequestsError, checkRateLimit, defaultKeyGenerator, getRateLimitHeaders };
81
-
82
84
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class TooManyRequestsError extends Error {\n\tpublic readonly statusCode = 429;\n\tpublic readonly retryAfter?: number;\n\n\tconstructor(message?: string, retryAfter?: number) {\n\t\tsuper(message || 'Too many requests, please try again later.');\n\t\tthis.name = 'TooManyRequestsError';\n\t\tthis.retryAfter = retryAfter;\n\t}\n}\n\n/**\n * Rate limit configuration for an endpoint\n */\nexport interface RateLimitConfig {\n\t/**\n\t * Maximum number of requests allowed in the window\n\t */\n\tlimit: number;\n\n\t/**\n\t * Time window in milliseconds\n\t */\n\twindowMs: number;\n\n\t/**\n\t * Cache instance to store rate limit data\n\t */\n\tcache: Cache;\n\n\t/**\n\t * Key generator function to identify clients\n\t * Defaults to using IP address\n\t */\n\tkeyGenerator?: RateLimitKeyGenerator;\n\n\t/**\n\t * Skip rate limiting for certain requests\n\t */\n\tskip?: RateLimitSkipFn;\n\n\t/**\n\t * Optional message to return when rate limit is exceeded\n\t */\n\tmessage?: string;\n\n\t/**\n\t * Optional custom handler when rate limit is exceeded\n\t */\n\thandler?: RateLimitExceededHandler;\n\n\t/**\n\t * Whether to include rate limit headers in response\n\t * @default true\n\t */\n\tstandardHeaders?: boolean;\n\n\t/**\n\t * Whether to include legacy rate limit headers\n\t * @default false\n\t */\n\tlegacyHeaders?: boolean;\n}\n\n/**\n * Context for rate limiting decisions\n */\nexport interface RateLimitContext<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> {\n\theader: (key: string) => string | undefined;\n\tservices: ServiceRecord<TServices>;\n\tlogger: TLogger;\n\tsession: TSession;\n\tpath: string;\n\tmethod: string;\n}\n\n/**\n * Function to generate a unique key for rate limiting\n */\nexport type RateLimitKeyGenerator<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => string | Promise<string>;\n\n/**\n * Function to determine if rate limiting should be skipped\n */\nexport type RateLimitSkipFn<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => boolean | Promise<boolean>;\n\n/**\n * Handler for when rate limit is exceeded\n */\nexport type RateLimitExceededHandler<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n\tinfo: RateLimitInfo,\n) => void | Promise<void>;\n\n/**\n * Information about current rate limit status\n */\nexport interface RateLimitInfo {\n\t/**\n\t * Current request count in the window\n\t */\n\tcount: number;\n\n\t/**\n\t * Maximum allowed requests\n\t */\n\tlimit: number;\n\n\t/**\n\t * Remaining requests allowed\n\t */\n\tremaining: number;\n\n\t/**\n\t * Time when the window resets (Unix timestamp)\n\t */\n\tresetTime: number;\n\n\t/**\n\t * Time until reset in milliseconds\n\t */\n\tretryAfter: number;\n}\n\n/**\n * Headers to be set on responses\n */\nexport interface RateLimitHeaders {\n\t'X-RateLimit-Limit'?: string;\n\t'X-RateLimit-Remaining'?: string;\n\t'X-RateLimit-Reset'?: string;\n\t'Retry-After'?: string;\n\t'X-RateLimit-Retry-After'?: string;\n\t'X-RateLimit-Reset-After'?: string;\n}\n\n/**\n * Data stored in cache for rate limiting\n */\nexport interface RateLimitData {\n\tcount: number;\n\tresetTime: number;\n}\n\n/**\n * Default key generator using IP address\n */\nexport const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {\n\t// Try various headers for IP address\n\tconst ip =\n\t\tctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||\n\t\tctx.header('x-real-ip') ||\n\t\tctx.header('x-client-ip') ||\n\t\tctx.header('cf-connecting-ip') ||\n\t\t'unknown';\n\n\treturn `rate-limit:${ctx.method}:${ctx.path}:${ip}`;\n};\n\n/**\n * Check rate limit and throw error if exceeded\n */\nexport async function checkRateLimit<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n>(\n\tconfig: RateLimitConfig,\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n): Promise<RateLimitInfo> {\n\t// Check if we should skip rate limiting\n\tif (config.skip && (await config.skip(ctx))) {\n\t\treturn {\n\t\t\tcount: 0,\n\t\t\tlimit: config.limit,\n\t\t\tremaining: config.limit,\n\t\t\tresetTime: Date.now() + config.windowMs,\n\t\t\tretryAfter: config.windowMs,\n\t\t};\n\t}\n\n\t// Generate key for this request\n\tconst keyGenerator = config.keyGenerator || defaultKeyGenerator;\n\tconst key = await keyGenerator(ctx);\n\n\t// Get current data from cache\n\tconst now = Date.now();\n\tlet data = await config.cache.get<RateLimitData>(key);\n\n\t// If no data or window expired, create new entry\n\tif (!data || data.resetTime <= now) {\n\t\tconst resetTime = now + config.windowMs;\n\t\tdata = { count: 1, resetTime };\n\n\t\t// Store with TTL matching the window\n\t\tconst ttlSeconds = Math.ceil(config.windowMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t} else {\n\t\t// Increment count\n\t\tdata.count++;\n\n\t\t// Calculate remaining TTL\n\t\tconst remainingMs = data.resetTime - now;\n\t\tconst ttlSeconds = Math.ceil(remainingMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t}\n\n\t// Calculate rate limit info\n\tconst info: RateLimitInfo = {\n\t\tcount: data.count,\n\t\tlimit: config.limit,\n\t\tremaining: Math.max(0, config.limit - data.count),\n\t\tresetTime: data.resetTime,\n\t\tretryAfter: data.resetTime - now,\n\t};\n\n\t// Check if limit exceeded\n\tif (data.count > config.limit) {\n\t\t// Call custom handler if provided\n\t\tif (config.handler) {\n\t\t\tawait config.handler(ctx, info);\n\t\t}\n\n\t\t// Throw rate limit error\n\t\tconst retryAfterSeconds = Math.ceil(info.retryAfter / 1000);\n\t\tthrow new TooManyRequestsError(\n\t\t\tconfig.message || 'Too many requests, please try again later.',\n\t\t\tretryAfterSeconds,\n\t\t);\n\t}\n\n\treturn info;\n}\n\n/**\n * Generate rate limit headers\n */\nexport function getRateLimitHeaders(\n\tinfo: RateLimitInfo,\n\tconfig: RateLimitConfig,\n): RateLimitHeaders {\n\tconst headers: RateLimitHeaders = {};\n\n\tif (config.standardHeaders !== false) {\n\t\theaders['X-RateLimit-Limit'] = info.limit.toString();\n\t\theaders['X-RateLimit-Remaining'] = info.remaining.toString();\n\t\theaders['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();\n\t}\n\n\tif (config.legacyHeaders) {\n\t\theaders['X-RateLimit-Retry-After'] = info.retryAfter.toString();\n\t\theaders['X-RateLimit-Reset-After'] = Math.ceil(\n\t\t\tinfo.retryAfter / 1000,\n\t\t).toString();\n\t}\n\n\t// Always set Retry-After when limit is exceeded\n\tif (info.remaining === 0) {\n\t\theaders['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();\n\t}\n\n\treturn headers;\n}\n"],"mappings":";;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;CAC/C,aAA6B;CAC7B;CAEA,YAAY,SAAkB,YAAqB;EAClD,MAAM,WAAW,4CAA4C;EAC7D,KAAK,OAAO;EACZ,KAAK,aAAa;CACnB;AACD;;;;AA8JA,MAAa,uBAA8C,QAAQ;CAElE,MAAM,KACL,IAAI,OAAO,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KACnD,IAAI,OAAO,WAAW,KACtB,IAAI,OAAO,aAAa,KACxB,IAAI,OAAO,kBAAkB,KAC7B;CAED,OAAO,cAAc,IAAI,OAAO,GAAG,IAAI,KAAK,GAAG;AAChD;;;;AAKA,eAAsB,eAKrB,QACA,KACyB;CAEzB,IAAI,OAAO,QAAS,MAAM,OAAO,KAAK,GAAG,GACxC,OAAO;EACN,OAAO;EACP,OAAO,OAAO;EACd,WAAW,OAAO;EAClB,WAAW,KAAK,IAAI,IAAI,OAAO;EAC/B,YAAY,OAAO;CACpB;CAKD,MAAM,MAAM,OADS,OAAO,gBAAgB,oBAAA,CACb,GAAG;CAGlC,MAAM,MAAM,KAAK,IAAI;CACrB,IAAI,OAAO,MAAM,OAAO,MAAM,IAAmB,GAAG;CAGpD,IAAI,CAAC,QAAQ,KAAK,aAAa,KAAK;EAEnC,OAAO;GAAE,OAAO;GAAG,WADD,MAAM,OAAO;EACF;EAG7B,MAAM,aAAa,KAAK,KAAK,OAAO,WAAW,GAAI;EACnD,MAAM,OAAO,MAAM,IAAI,KAAK,MAAM,UAAU;CAC7C,OAAO;EAEN,KAAK;EAGL,MAAM,cAAc,KAAK,YAAY;EACrC,MAAM,aAAa,KAAK,KAAK,cAAc,GAAI;EAC/C,MAAM,OAAO,MAAM,IAAI,KAAK,MAAM,UAAU;CAC7C;CAGA,MAAM,OAAsB;EAC3B,OAAO,KAAK;EACZ,OAAO,OAAO;EACd,WAAW,KAAK,IAAI,GAAG,OAAO,QAAQ,KAAK,KAAK;EAChD,WAAW,KAAK;EAChB,YAAY,KAAK,YAAY;CAC9B;CAGA,IAAI,KAAK,QAAQ,OAAO,OAAO;EAE9B,IAAI,OAAO,SACV,MAAM,OAAO,QAAQ,KAAK,IAAI;EAI/B,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,GAAI;EAC1D,MAAM,IAAI,qBACT,OAAO,WAAW,8CAClB,iBACD;CACD;CAEA,OAAO;AACR;;;;AAKA,SAAgB,oBACf,MACA,QACmB;CACnB,MAAM,UAA4B,CAAC;CAEnC,IAAI,OAAO,oBAAoB,OAAO;EACrC,QAAQ,uBAAuB,KAAK,MAAM,SAAS;EACnD,QAAQ,2BAA2B,KAAK,UAAU,SAAS;EAC3D,QAAQ,uBAAuB,IAAI,KAAK,KAAK,SAAS,CAAC,CAAC,YAAY;CACrE;CAEA,IAAI,OAAO,eAAe;EACzB,QAAQ,6BAA6B,KAAK,WAAW,SAAS;EAC9D,QAAQ,6BAA6B,KAAK,KACzC,KAAK,aAAa,GACnB,CAAC,CAAC,SAAS;CACZ;CAGA,IAAI,KAAK,cAAc,GACtB,QAAQ,iBAAiB,KAAK,KAAK,KAAK,aAAa,GAAI,CAAC,CAAC,SAAS;CAGrE,OAAO;AACR"}
1
+ {"version":3,"file":"index.mjs","names":["message?: string","retryAfter?: number","defaultKeyGenerator: RateLimitKeyGenerator","config: RateLimitConfig","ctx: RateLimitContext<TServices, TLogger, TSession>","info: RateLimitInfo","headers: RateLimitHeaders"],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class TooManyRequestsError extends Error {\n\tpublic readonly statusCode = 429;\n\tpublic readonly retryAfter?: number;\n\n\tconstructor(message?: string, retryAfter?: number) {\n\t\tsuper(message || 'Too many requests, please try again later.');\n\t\tthis.name = 'TooManyRequestsError';\n\t\tthis.retryAfter = retryAfter;\n\t}\n}\n\n/**\n * Rate limit configuration for an endpoint\n */\nexport interface RateLimitConfig {\n\t/**\n\t * Maximum number of requests allowed in the window\n\t */\n\tlimit: number;\n\n\t/**\n\t * Time window in milliseconds\n\t */\n\twindowMs: number;\n\n\t/**\n\t * Cache instance to store rate limit data\n\t */\n\tcache: Cache;\n\n\t/**\n\t * Key generator function to identify clients\n\t * Defaults to using IP address\n\t */\n\tkeyGenerator?: RateLimitKeyGenerator;\n\n\t/**\n\t * Skip rate limiting for certain requests\n\t */\n\tskip?: RateLimitSkipFn;\n\n\t/**\n\t * Optional message to return when rate limit is exceeded\n\t */\n\tmessage?: string;\n\n\t/**\n\t * Optional custom handler when rate limit is exceeded\n\t */\n\thandler?: RateLimitExceededHandler;\n\n\t/**\n\t * Whether to include rate limit headers in response\n\t * @default true\n\t */\n\tstandardHeaders?: boolean;\n\n\t/**\n\t * Whether to include legacy rate limit headers\n\t * @default false\n\t */\n\tlegacyHeaders?: boolean;\n}\n\n/**\n * Context for rate limiting decisions\n */\nexport interface RateLimitContext<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> {\n\theader: (key: string) => string | undefined;\n\tservices: ServiceRecord<TServices>;\n\tlogger: TLogger;\n\tsession: TSession;\n\tpath: string;\n\tmethod: string;\n}\n\n/**\n * Function to generate a unique key for rate limiting\n */\nexport type RateLimitKeyGenerator<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => string | Promise<string>;\n\n/**\n * Function to determine if rate limiting should be skipped\n */\nexport type RateLimitSkipFn<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n) => boolean | Promise<boolean>;\n\n/**\n * Handler for when rate limit is exceeded\n */\nexport type RateLimitExceededHandler<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n> = (\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n\tinfo: RateLimitInfo,\n) => void | Promise<void>;\n\n/**\n * Information about current rate limit status\n */\nexport interface RateLimitInfo {\n\t/**\n\t * Current request count in the window\n\t */\n\tcount: number;\n\n\t/**\n\t * Maximum allowed requests\n\t */\n\tlimit: number;\n\n\t/**\n\t * Remaining requests allowed\n\t */\n\tremaining: number;\n\n\t/**\n\t * Time when the window resets (Unix timestamp)\n\t */\n\tresetTime: number;\n\n\t/**\n\t * Time until reset in milliseconds\n\t */\n\tretryAfter: number;\n}\n\n/**\n * Headers to be set on responses\n */\nexport interface RateLimitHeaders {\n\t'X-RateLimit-Limit'?: string;\n\t'X-RateLimit-Remaining'?: string;\n\t'X-RateLimit-Reset'?: string;\n\t'Retry-After'?: string;\n\t'X-RateLimit-Retry-After'?: string;\n\t'X-RateLimit-Reset-After'?: string;\n}\n\n/**\n * Data stored in cache for rate limiting\n */\nexport interface RateLimitData {\n\tcount: number;\n\tresetTime: number;\n}\n\n/**\n * Default key generator using IP address\n */\nexport const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {\n\t// Try various headers for IP address\n\tconst ip =\n\t\tctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||\n\t\tctx.header('x-real-ip') ||\n\t\tctx.header('x-client-ip') ||\n\t\tctx.header('cf-connecting-ip') ||\n\t\t'unknown';\n\n\treturn `rate-limit:${ctx.method}:${ctx.path}:${ip}`;\n};\n\n/**\n * Check rate limit and throw error if exceeded\n */\nexport async function checkRateLimit<\n\tTServices extends Service[] = [],\n\tTLogger extends Logger = Logger,\n\tTSession = unknown,\n>(\n\tconfig: RateLimitConfig,\n\tctx: RateLimitContext<TServices, TLogger, TSession>,\n): Promise<RateLimitInfo> {\n\t// Check if we should skip rate limiting\n\tif (config.skip && (await config.skip(ctx))) {\n\t\treturn {\n\t\t\tcount: 0,\n\t\t\tlimit: config.limit,\n\t\t\tremaining: config.limit,\n\t\t\tresetTime: Date.now() + config.windowMs,\n\t\t\tretryAfter: config.windowMs,\n\t\t};\n\t}\n\n\t// Generate key for this request\n\tconst keyGenerator = config.keyGenerator || defaultKeyGenerator;\n\tconst key = await keyGenerator(ctx);\n\n\t// Get current data from cache\n\tconst now = Date.now();\n\tlet data = await config.cache.get<RateLimitData>(key);\n\n\t// If no data or window expired, create new entry\n\tif (!data || data.resetTime <= now) {\n\t\tconst resetTime = now + config.windowMs;\n\t\tdata = { count: 1, resetTime };\n\n\t\t// Store with TTL matching the window\n\t\tconst ttlSeconds = Math.ceil(config.windowMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t} else {\n\t\t// Increment count\n\t\tdata.count++;\n\n\t\t// Calculate remaining TTL\n\t\tconst remainingMs = data.resetTime - now;\n\t\tconst ttlSeconds = Math.ceil(remainingMs / 1000);\n\t\tawait config.cache.set(key, data, ttlSeconds);\n\t}\n\n\t// Calculate rate limit info\n\tconst info: RateLimitInfo = {\n\t\tcount: data.count,\n\t\tlimit: config.limit,\n\t\tremaining: Math.max(0, config.limit - data.count),\n\t\tresetTime: data.resetTime,\n\t\tretryAfter: data.resetTime - now,\n\t};\n\n\t// Check if limit exceeded\n\tif (data.count > config.limit) {\n\t\t// Call custom handler if provided\n\t\tif (config.handler) {\n\t\t\tawait config.handler(ctx, info);\n\t\t}\n\n\t\t// Throw rate limit error\n\t\tconst retryAfterSeconds = Math.ceil(info.retryAfter / 1000);\n\t\tthrow new TooManyRequestsError(\n\t\t\tconfig.message || 'Too many requests, please try again later.',\n\t\t\tretryAfterSeconds,\n\t\t);\n\t}\n\n\treturn info;\n}\n\n/**\n * Generate rate limit headers\n */\nexport function getRateLimitHeaders(\n\tinfo: RateLimitInfo,\n\tconfig: RateLimitConfig,\n): RateLimitHeaders {\n\tconst headers: RateLimitHeaders = {};\n\n\tif (config.standardHeaders !== false) {\n\t\theaders['X-RateLimit-Limit'] = info.limit.toString();\n\t\theaders['X-RateLimit-Remaining'] = info.remaining.toString();\n\t\theaders['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();\n\t}\n\n\tif (config.legacyHeaders) {\n\t\theaders['X-RateLimit-Retry-After'] = info.retryAfter.toString();\n\t\theaders['X-RateLimit-Reset-After'] = Math.ceil(\n\t\t\tinfo.retryAfter / 1000,\n\t\t).toString();\n\t}\n\n\t// Always set Retry-After when limit is exceeded\n\tif (info.remaining === 0) {\n\t\theaders['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();\n\t}\n\n\treturn headers;\n}\n"],"mappings":";;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;CAC/C,AAAgB,aAAa;CAC7B,AAAgB;CAEhB,YAAYA,SAAkBC,YAAqB;AAClD,QAAM,WAAW,6CAA6C;AAC9D,OAAK,OAAO;AACZ,OAAK,aAAa;CAClB;AACD;;;;AA8JD,MAAaC,sBAA6C,CAAC,QAAQ;CAElE,MAAM,KACL,IAAI,OAAO,kBAAkB,EAAE,MAAM,IAAI,CAAC,IAAI,MAAM,IACpD,IAAI,OAAO,YAAY,IACvB,IAAI,OAAO,cAAc,IACzB,IAAI,OAAO,mBAAmB,IAC9B;AAED,SAAQ,aAAa,IAAI,OAAO,GAAG,IAAI,KAAK,GAAG,GAAG;AAClD;;;;AAKD,eAAsB,eAKrBC,QACAC,KACyB;AAEzB,KAAI,OAAO,QAAS,MAAM,OAAO,KAAK,IAAI,CACzC,QAAO;EACN,OAAO;EACP,OAAO,OAAO;EACd,WAAW,OAAO;EAClB,WAAW,KAAK,KAAK,GAAG,OAAO;EAC/B,YAAY,OAAO;CACnB;CAIF,MAAM,eAAe,OAAO,gBAAgB;CAC5C,MAAM,MAAM,MAAM,aAAa,IAAI;CAGnC,MAAM,MAAM,KAAK,KAAK;CACtB,IAAI,OAAO,MAAM,OAAO,MAAM,IAAmB,IAAI;AAGrD,MAAK,QAAQ,KAAK,aAAa,KAAK;EACnC,MAAM,YAAY,MAAM,OAAO;AAC/B,SAAO;GAAE,OAAO;GAAG;EAAW;EAG9B,MAAM,aAAa,KAAK,KAAK,OAAO,WAAW,IAAK;AACpD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC7C,OAAM;AAEN,OAAK;EAGL,MAAM,cAAc,KAAK,YAAY;EACrC,MAAM,aAAa,KAAK,KAAK,cAAc,IAAK;AAChD,QAAM,OAAO,MAAM,IAAI,KAAK,MAAM,WAAW;CAC7C;CAGD,MAAMC,OAAsB;EAC3B,OAAO,KAAK;EACZ,OAAO,OAAO;EACd,WAAW,KAAK,IAAI,GAAG,OAAO,QAAQ,KAAK,MAAM;EACjD,WAAW,KAAK;EAChB,YAAY,KAAK,YAAY;CAC7B;AAGD,KAAI,KAAK,QAAQ,OAAO,OAAO;AAE9B,MAAI,OAAO,QACV,OAAM,OAAO,QAAQ,KAAK,KAAK;EAIhC,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,IAAK;AAC3D,QAAM,IAAI,qBACT,OAAO,WAAW,8CAClB;CAED;AAED,QAAO;AACP;;;;AAKD,SAAgB,oBACfA,MACAF,QACmB;CACnB,MAAMG,UAA4B,CAAE;AAEpC,KAAI,OAAO,oBAAoB,OAAO;AACrC,UAAQ,uBAAuB,KAAK,MAAM,UAAU;AACpD,UAAQ,2BAA2B,KAAK,UAAU,UAAU;AAC5D,UAAQ,uBAAuB,IAAI,KAAK,KAAK,WAAW,aAAa;CACrE;AAED,KAAI,OAAO,eAAe;AACzB,UAAQ,6BAA6B,KAAK,WAAW,UAAU;AAC/D,UAAQ,6BAA6B,KAAK,KACzC,KAAK,aAAa,IAClB,CAAC,UAAU;CACZ;AAGD,KAAI,KAAK,cAAc,EACtB,SAAQ,iBAAiB,KAAK,KAAK,KAAK,aAAa,IAAK,CAAC,UAAU;AAGtE,QAAO;AACP"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/rate-limit",
3
- "version": "10.0.0-alpha.99",
3
+ "version": "10.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,9 +15,6 @@
15
15
  }
16
16
  }
17
17
  },
18
- "files": [
19
- "dist"
20
- ],
21
18
  "repository": {
22
19
  "type": "git",
23
20
  "url": "https://github.com/geekmidas/toolbox"
@@ -28,9 +25,9 @@
28
25
  },
29
26
  "dependencies": {},
30
27
  "peerDependencies": {
31
- "@geekmidas/cache": "^10.0.0-alpha.99",
32
- "@geekmidas/logger": "^10.0.0-alpha.99",
33
- "@geekmidas/services": "^10.0.0-alpha.99"
28
+ "@geekmidas/cache": "^10.0.0",
29
+ "@geekmidas/logger": "^10.0.0",
30
+ "@geekmidas/services": "^10.0.0"
34
31
  },
35
32
  "peerDependenciesMeta": {
36
33
  "@geekmidas/cache": {
@@ -44,12 +41,11 @@
44
41
  }
45
42
  },
46
43
  "devDependencies": {
47
- "@geekmidas/cache": "^10.0.0-alpha.99",
48
- "@geekmidas/logger": "^10.0.0-alpha.99",
49
- "@geekmidas/services": "^10.0.0-alpha.99"
44
+ "@geekmidas/cache": "^10.0.0",
45
+ "@geekmidas/logger": "^10.0.0",
46
+ "@geekmidas/services": "^10.0.0"
50
47
  },
51
48
  "scripts": {
52
- "ts": "tsc --noEmit --skipLibCheck src/**/*.ts",
53
- "build": "tsdown --config ../../tsdown.config.ts --filter ."
49
+ "ts": "tsc --noEmit --skipLibCheck src/**/*.ts"
54
50
  }
55
51
  }
@@ -0,0 +1,109 @@
1
+ import { InMemoryCache } from '@geekmidas/cache/memory';
2
+ import { bench, describe } from 'vitest';
3
+ import {
4
+ checkRateLimit,
5
+ type RateLimitConfig,
6
+ type RateLimitContext,
7
+ } from '../index';
8
+
9
+ // Helper to create a mock context
10
+ function createContext(ip: string): RateLimitContext {
11
+ return {
12
+ header: (key: string) => (key === 'x-forwarded-for' ? ip : undefined),
13
+ services: {},
14
+ logger: {
15
+ info: () => {},
16
+ error: () => {},
17
+ debug: () => {},
18
+ warn: () => {},
19
+ } as any,
20
+ session: {},
21
+ path: '/test',
22
+ method: 'GET',
23
+ };
24
+ }
25
+
26
+ describe('Rate Limiting', () => {
27
+ const cache = new InMemoryCache();
28
+ const config: RateLimitConfig = {
29
+ limit: 100,
30
+ windowMs: 60000,
31
+ cache,
32
+ };
33
+
34
+ bench('checkRateLimit - single IP', async () => {
35
+ const ctx = createContext('127.0.0.1');
36
+ await checkRateLimit(config, ctx);
37
+ });
38
+
39
+ bench('checkRateLimit - varying IPs', async () => {
40
+ const ip = `192.168.1.${Math.floor(Math.random() * 255)}`;
41
+ const ctx = createContext(ip);
42
+ await checkRateLimit(config, ctx);
43
+ });
44
+ });
45
+
46
+ describe('Rate Limiting - High Volume', () => {
47
+ bench('100 requests same IP', async () => {
48
+ const cache = new InMemoryCache();
49
+ const config: RateLimitConfig = {
50
+ limit: 1000,
51
+ windowMs: 60000,
52
+ cache,
53
+ };
54
+ const ctx = createContext('10.0.0.1');
55
+
56
+ for (let i = 0; i < 100; i++) {
57
+ await checkRateLimit(config, ctx);
58
+ }
59
+ });
60
+
61
+ bench('100 requests different IPs', async () => {
62
+ const cache = new InMemoryCache();
63
+ const config: RateLimitConfig = {
64
+ limit: 100,
65
+ windowMs: 60000,
66
+ cache,
67
+ };
68
+
69
+ for (let i = 0; i < 100; i++) {
70
+ const ctx = createContext(`10.0.0.${i}`);
71
+ await checkRateLimit(config, ctx);
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('Rate Limiting - Window Sizes', () => {
77
+ bench('1 second window', async () => {
78
+ const cache = new InMemoryCache();
79
+ const config: RateLimitConfig = {
80
+ limit: 10,
81
+ windowMs: 1000,
82
+ cache,
83
+ };
84
+ const ctx = createContext('127.0.0.1');
85
+ await checkRateLimit(config, ctx);
86
+ });
87
+
88
+ bench('1 minute window', async () => {
89
+ const cache = new InMemoryCache();
90
+ const config: RateLimitConfig = {
91
+ limit: 100,
92
+ windowMs: 60000,
93
+ cache,
94
+ };
95
+ const ctx = createContext('127.0.0.1');
96
+ await checkRateLimit(config, ctx);
97
+ });
98
+
99
+ bench('1 hour window', async () => {
100
+ const cache = new InMemoryCache();
101
+ const config: RateLimitConfig = {
102
+ limit: 1000,
103
+ windowMs: 3600000,
104
+ cache,
105
+ };
106
+ const ctx = createContext('127.0.0.1');
107
+ await checkRateLimit(config, ctx);
108
+ });
109
+ });
@@ -0,0 +1,321 @@
1
+ import { InMemoryCache } from '@geekmidas/cache/memory';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ checkRateLimit,
5
+ defaultKeyGenerator,
6
+ getRateLimitHeaders,
7
+ type RateLimitConfig,
8
+ type RateLimitContext,
9
+ type RateLimitData,
10
+ TooManyRequestsError,
11
+ } from '../index';
12
+
13
+ describe('Rate Limiting', () => {
14
+ const createContext = (
15
+ overrides?: Partial<RateLimitContext>,
16
+ ): RateLimitContext => ({
17
+ header: vi.fn((key: string) => {
18
+ if (key === 'x-forwarded-for') return '192.168.1.1';
19
+ return undefined;
20
+ }),
21
+ services: [],
22
+ logger: {
23
+ debug: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ child: vi.fn().mockReturnThis(),
28
+ } as any,
29
+ session: {},
30
+ path: '/api/test',
31
+ method: 'GET',
32
+ ...overrides,
33
+ });
34
+
35
+ describe('defaultKeyGenerator', () => {
36
+ it('should generate key with IP from x-forwarded-for', () => {
37
+ const ctx = createContext();
38
+ const key = defaultKeyGenerator(ctx);
39
+ expect(key).toBe('rate-limit:GET:/api/test:192.168.1.1');
40
+ });
41
+
42
+ it('should use x-real-ip when x-forwarded-for is not available', () => {
43
+ const ctx = createContext({
44
+ header: vi.fn((key: string) => {
45
+ if (key === 'x-real-ip') return '10.0.0.1';
46
+ return undefined;
47
+ }),
48
+ });
49
+ const key = defaultKeyGenerator(ctx);
50
+ expect(key).toBe('rate-limit:GET:/api/test:10.0.0.1');
51
+ });
52
+
53
+ it('should use unknown when no IP header is available', () => {
54
+ const ctx = createContext({
55
+ header: vi.fn(() => undefined),
56
+ });
57
+ const key = defaultKeyGenerator(ctx);
58
+ expect(key).toBe('rate-limit:GET:/api/test:unknown');
59
+ });
60
+
61
+ it('should handle comma-separated IPs in x-forwarded-for', () => {
62
+ const ctx = createContext({
63
+ header: vi.fn((key: string) => {
64
+ if (key === 'x-forwarded-for')
65
+ return '192.168.1.1, 10.0.0.1, 172.16.0.1';
66
+ return undefined;
67
+ }),
68
+ });
69
+ const key = defaultKeyGenerator(ctx);
70
+ expect(key).toBe('rate-limit:GET:/api/test:192.168.1.1');
71
+ });
72
+ });
73
+
74
+ describe('checkRateLimit', () => {
75
+ it('should allow requests within the limit', async () => {
76
+ const cache = new InMemoryCache<RateLimitData>();
77
+ const config: RateLimitConfig<RateLimitData> = {
78
+ limit: 5,
79
+ windowMs: 60000, // 1 minute
80
+ cache,
81
+ };
82
+ const ctx = createContext();
83
+
84
+ const info = await checkRateLimit(config, ctx);
85
+ expect(info.count).toBe(1);
86
+ expect(info.remaining).toBe(4);
87
+ expect(info.limit).toBe(5);
88
+ });
89
+
90
+ it('should increment count on subsequent requests', async () => {
91
+ const cache = new InMemoryCache<RateLimitData>();
92
+ const config: RateLimitConfig<RateLimitData> = {
93
+ limit: 5,
94
+ windowMs: 60000,
95
+ cache,
96
+ };
97
+ const ctx = createContext();
98
+
99
+ // First request
100
+ await checkRateLimit(config, ctx);
101
+
102
+ // Second request
103
+ const info = await checkRateLimit(config, ctx);
104
+ expect(info.count).toBe(2);
105
+ expect(info.remaining).toBe(3);
106
+ });
107
+
108
+ it('should throw TooManyRequestsError when limit is exceeded', async () => {
109
+ const cache = new InMemoryCache<RateLimitData>();
110
+ const config: RateLimitConfig<RateLimitData> = {
111
+ limit: 2,
112
+ windowMs: 60000,
113
+ cache,
114
+ };
115
+ const ctx = createContext();
116
+
117
+ // Use up the limit
118
+ await checkRateLimit(config, ctx);
119
+ await checkRateLimit(config, ctx);
120
+
121
+ // Exceed the limit
122
+ await expect(checkRateLimit(config, ctx)).rejects.toThrow(
123
+ TooManyRequestsError,
124
+ );
125
+ });
126
+
127
+ it('should use custom message when rate limit is exceeded', async () => {
128
+ const cache = new InMemoryCache<RateLimitData>();
129
+ const config: RateLimitConfig<RateLimitData> = {
130
+ limit: 1,
131
+ windowMs: 60000,
132
+ cache,
133
+ message: 'Custom rate limit message',
134
+ };
135
+ const ctx = createContext();
136
+
137
+ await checkRateLimit(config, ctx);
138
+
139
+ try {
140
+ await checkRateLimit(config, ctx);
141
+ } catch (error) {
142
+ expect(error).toBeInstanceOf(TooManyRequestsError);
143
+ expect((error as TooManyRequestsError).message).toBe(
144
+ 'Custom rate limit message',
145
+ );
146
+ }
147
+ });
148
+
149
+ it('should skip rate limiting when skip function returns true', async () => {
150
+ const cache = new InMemoryCache<RateLimitData>();
151
+ const config: RateLimitConfig<RateLimitData> = {
152
+ limit: 1,
153
+ windowMs: 60000,
154
+ cache,
155
+ skip: vi.fn().mockResolvedValue(true),
156
+ };
157
+ const ctx = createContext();
158
+
159
+ // Should not throw even though limit would be exceeded
160
+ await checkRateLimit(config, ctx);
161
+ const info = await checkRateLimit(config, ctx);
162
+
163
+ expect(info.count).toBe(0);
164
+ expect(info.remaining).toBe(1);
165
+ expect(config.skip).toHaveBeenCalledWith(ctx);
166
+ });
167
+
168
+ it('should call custom handler when rate limit is exceeded', async () => {
169
+ const cache = new InMemoryCache<RateLimitData>();
170
+ const handler = vi.fn();
171
+ const config: RateLimitConfig<RateLimitData> = {
172
+ limit: 1,
173
+ windowMs: 60000,
174
+ cache,
175
+ handler,
176
+ };
177
+ const ctx = createContext();
178
+
179
+ await checkRateLimit(config, ctx);
180
+
181
+ try {
182
+ await checkRateLimit(config, ctx);
183
+ } catch {
184
+ // Expected to throw
185
+ }
186
+
187
+ expect(handler).toHaveBeenCalledWith(
188
+ ctx,
189
+ expect.objectContaining({
190
+ count: 2,
191
+ limit: 1,
192
+ remaining: 0,
193
+ }),
194
+ );
195
+ });
196
+
197
+ it('should use custom key generator', async () => {
198
+ const cache = new InMemoryCache<RateLimitData>();
199
+ const keyGenerator = vi.fn().mockReturnValue('custom-key');
200
+ const config: RateLimitConfig<RateLimitData> = {
201
+ limit: 5,
202
+ windowMs: 60000,
203
+ cache,
204
+ keyGenerator,
205
+ };
206
+ const ctx = createContext();
207
+
208
+ await checkRateLimit(config, ctx);
209
+
210
+ expect(keyGenerator).toHaveBeenCalledWith(ctx);
211
+ });
212
+
213
+ it('should reset count after window expires', async () => {
214
+ const cache = new InMemoryCache<RateLimitData>();
215
+ const config: RateLimitConfig<RateLimitData> = {
216
+ limit: 2,
217
+ windowMs: 100, // 100ms window
218
+ cache,
219
+ };
220
+ const ctx = createContext();
221
+
222
+ // Use up the limit
223
+ await checkRateLimit(config, ctx);
224
+ await checkRateLimit(config, ctx);
225
+
226
+ // Wait for window to expire
227
+ await new Promise((resolve) => setTimeout(resolve, 150));
228
+
229
+ // Should be allowed again
230
+ const info = await checkRateLimit(config, ctx);
231
+ expect(info.count).toBe(1);
232
+ expect(info.remaining).toBe(1);
233
+ });
234
+ });
235
+
236
+ describe('getRateLimitHeaders', () => {
237
+ it('should generate standard headers by default', () => {
238
+ const info = {
239
+ count: 3,
240
+ limit: 10,
241
+ remaining: 7,
242
+ resetTime: Date.now() + 60000,
243
+ retryAfter: 60000,
244
+ };
245
+ const config: RateLimitConfig = {
246
+ limit: 10,
247
+ windowMs: 60000,
248
+ cache: new InMemoryCache(),
249
+ };
250
+
251
+ const headers = getRateLimitHeaders(info, config);
252
+
253
+ expect(headers['X-RateLimit-Limit']).toBe('10');
254
+ expect(headers['X-RateLimit-Remaining']).toBe('7');
255
+ expect(headers['X-RateLimit-Reset']).toMatch(/^\d{4}-\d{2}-\d{2}T/);
256
+ expect(headers['Retry-After']).toBeUndefined();
257
+ });
258
+
259
+ it('should include Retry-After when limit is exceeded', () => {
260
+ const info = {
261
+ count: 11,
262
+ limit: 10,
263
+ remaining: 0,
264
+ resetTime: Date.now() + 60000,
265
+ retryAfter: 60000,
266
+ };
267
+ const config: RateLimitConfig = {
268
+ limit: 10,
269
+ windowMs: 60000,
270
+ cache: new InMemoryCache(),
271
+ };
272
+
273
+ const headers = getRateLimitHeaders(info, config);
274
+
275
+ expect(headers['Retry-After']).toBe('60');
276
+ });
277
+
278
+ it('should not include standard headers when disabled', () => {
279
+ const info = {
280
+ count: 3,
281
+ limit: 10,
282
+ remaining: 7,
283
+ resetTime: Date.now() + 60000,
284
+ retryAfter: 60000,
285
+ };
286
+ const config: RateLimitConfig = {
287
+ limit: 10,
288
+ windowMs: 60000,
289
+ cache: new InMemoryCache(),
290
+ standardHeaders: false,
291
+ };
292
+
293
+ const headers = getRateLimitHeaders(info, config);
294
+
295
+ expect(headers['X-RateLimit-Limit']).toBeUndefined();
296
+ expect(headers['X-RateLimit-Remaining']).toBeUndefined();
297
+ expect(headers['X-RateLimit-Reset']).toBeUndefined();
298
+ });
299
+
300
+ it('should include legacy headers when enabled', () => {
301
+ const info = {
302
+ count: 3,
303
+ limit: 10,
304
+ remaining: 7,
305
+ resetTime: Date.now() + 60000,
306
+ retryAfter: 60000,
307
+ };
308
+ const config: RateLimitConfig = {
309
+ limit: 10,
310
+ windowMs: 60000,
311
+ cache: new InMemoryCache(),
312
+ legacyHeaders: true,
313
+ };
314
+
315
+ const headers = getRateLimitHeaders(info, config);
316
+
317
+ expect(headers['X-RateLimit-Retry-After']).toBe('60000');
318
+ expect(headers['X-RateLimit-Reset-After']).toBe('60');
319
+ });
320
+ });
321
+ });
package/src/index.ts ADDED
@@ -0,0 +1,290 @@
1
+ import type { Cache } from '@geekmidas/cache';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import type { Service, ServiceRecord } from '@geekmidas/services';
4
+
5
+ /**
6
+ * Error thrown when rate limit is exceeded
7
+ */
8
+ export class TooManyRequestsError extends Error {
9
+ public readonly statusCode = 429;
10
+ public readonly retryAfter?: number;
11
+
12
+ constructor(message?: string, retryAfter?: number) {
13
+ super(message || 'Too many requests, please try again later.');
14
+ this.name = 'TooManyRequestsError';
15
+ this.retryAfter = retryAfter;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Rate limit configuration for an endpoint
21
+ */
22
+ export interface RateLimitConfig {
23
+ /**
24
+ * Maximum number of requests allowed in the window
25
+ */
26
+ limit: number;
27
+
28
+ /**
29
+ * Time window in milliseconds
30
+ */
31
+ windowMs: number;
32
+
33
+ /**
34
+ * Cache instance to store rate limit data
35
+ */
36
+ cache: Cache;
37
+
38
+ /**
39
+ * Key generator function to identify clients
40
+ * Defaults to using IP address
41
+ */
42
+ keyGenerator?: RateLimitKeyGenerator;
43
+
44
+ /**
45
+ * Skip rate limiting for certain requests
46
+ */
47
+ skip?: RateLimitSkipFn;
48
+
49
+ /**
50
+ * Optional message to return when rate limit is exceeded
51
+ */
52
+ message?: string;
53
+
54
+ /**
55
+ * Optional custom handler when rate limit is exceeded
56
+ */
57
+ handler?: RateLimitExceededHandler;
58
+
59
+ /**
60
+ * Whether to include rate limit headers in response
61
+ * @default true
62
+ */
63
+ standardHeaders?: boolean;
64
+
65
+ /**
66
+ * Whether to include legacy rate limit headers
67
+ * @default false
68
+ */
69
+ legacyHeaders?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Context for rate limiting decisions
74
+ */
75
+ export interface RateLimitContext<
76
+ TServices extends Service[] = [],
77
+ TLogger extends Logger = Logger,
78
+ TSession = unknown,
79
+ > {
80
+ header: (key: string) => string | undefined;
81
+ services: ServiceRecord<TServices>;
82
+ logger: TLogger;
83
+ session: TSession;
84
+ path: string;
85
+ method: string;
86
+ }
87
+
88
+ /**
89
+ * Function to generate a unique key for rate limiting
90
+ */
91
+ export type RateLimitKeyGenerator<
92
+ TServices extends Service[] = [],
93
+ TLogger extends Logger = Logger,
94
+ TSession = unknown,
95
+ > = (
96
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
97
+ ) => string | Promise<string>;
98
+
99
+ /**
100
+ * Function to determine if rate limiting should be skipped
101
+ */
102
+ export type RateLimitSkipFn<
103
+ TServices extends Service[] = [],
104
+ TLogger extends Logger = Logger,
105
+ TSession = unknown,
106
+ > = (
107
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
108
+ ) => boolean | Promise<boolean>;
109
+
110
+ /**
111
+ * Handler for when rate limit is exceeded
112
+ */
113
+ export type RateLimitExceededHandler<
114
+ TServices extends Service[] = [],
115
+ TLogger extends Logger = Logger,
116
+ TSession = unknown,
117
+ > = (
118
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
119
+ info: RateLimitInfo,
120
+ ) => void | Promise<void>;
121
+
122
+ /**
123
+ * Information about current rate limit status
124
+ */
125
+ export interface RateLimitInfo {
126
+ /**
127
+ * Current request count in the window
128
+ */
129
+ count: number;
130
+
131
+ /**
132
+ * Maximum allowed requests
133
+ */
134
+ limit: number;
135
+
136
+ /**
137
+ * Remaining requests allowed
138
+ */
139
+ remaining: number;
140
+
141
+ /**
142
+ * Time when the window resets (Unix timestamp)
143
+ */
144
+ resetTime: number;
145
+
146
+ /**
147
+ * Time until reset in milliseconds
148
+ */
149
+ retryAfter: number;
150
+ }
151
+
152
+ /**
153
+ * Headers to be set on responses
154
+ */
155
+ export interface RateLimitHeaders {
156
+ 'X-RateLimit-Limit'?: string;
157
+ 'X-RateLimit-Remaining'?: string;
158
+ 'X-RateLimit-Reset'?: string;
159
+ 'Retry-After'?: string;
160
+ 'X-RateLimit-Retry-After'?: string;
161
+ 'X-RateLimit-Reset-After'?: string;
162
+ }
163
+
164
+ /**
165
+ * Data stored in cache for rate limiting
166
+ */
167
+ export interface RateLimitData {
168
+ count: number;
169
+ resetTime: number;
170
+ }
171
+
172
+ /**
173
+ * Default key generator using IP address
174
+ */
175
+ export const defaultKeyGenerator: RateLimitKeyGenerator = (ctx) => {
176
+ // Try various headers for IP address
177
+ const ip =
178
+ ctx.header('x-forwarded-for')?.split(',')[0]?.trim() ||
179
+ ctx.header('x-real-ip') ||
180
+ ctx.header('x-client-ip') ||
181
+ ctx.header('cf-connecting-ip') ||
182
+ 'unknown';
183
+
184
+ return `rate-limit:${ctx.method}:${ctx.path}:${ip}`;
185
+ };
186
+
187
+ /**
188
+ * Check rate limit and throw error if exceeded
189
+ */
190
+ export async function checkRateLimit<
191
+ TServices extends Service[] = [],
192
+ TLogger extends Logger = Logger,
193
+ TSession = unknown,
194
+ >(
195
+ config: RateLimitConfig,
196
+ ctx: RateLimitContext<TServices, TLogger, TSession>,
197
+ ): Promise<RateLimitInfo> {
198
+ // Check if we should skip rate limiting
199
+ if (config.skip && (await config.skip(ctx))) {
200
+ return {
201
+ count: 0,
202
+ limit: config.limit,
203
+ remaining: config.limit,
204
+ resetTime: Date.now() + config.windowMs,
205
+ retryAfter: config.windowMs,
206
+ };
207
+ }
208
+
209
+ // Generate key for this request
210
+ const keyGenerator = config.keyGenerator || defaultKeyGenerator;
211
+ const key = await keyGenerator(ctx);
212
+
213
+ // Get current data from cache
214
+ const now = Date.now();
215
+ let data = await config.cache.get<RateLimitData>(key);
216
+
217
+ // If no data or window expired, create new entry
218
+ if (!data || data.resetTime <= now) {
219
+ const resetTime = now + config.windowMs;
220
+ data = { count: 1, resetTime };
221
+
222
+ // Store with TTL matching the window
223
+ const ttlSeconds = Math.ceil(config.windowMs / 1000);
224
+ await config.cache.set(key, data, ttlSeconds);
225
+ } else {
226
+ // Increment count
227
+ data.count++;
228
+
229
+ // Calculate remaining TTL
230
+ const remainingMs = data.resetTime - now;
231
+ const ttlSeconds = Math.ceil(remainingMs / 1000);
232
+ await config.cache.set(key, data, ttlSeconds);
233
+ }
234
+
235
+ // Calculate rate limit info
236
+ const info: RateLimitInfo = {
237
+ count: data.count,
238
+ limit: config.limit,
239
+ remaining: Math.max(0, config.limit - data.count),
240
+ resetTime: data.resetTime,
241
+ retryAfter: data.resetTime - now,
242
+ };
243
+
244
+ // Check if limit exceeded
245
+ if (data.count > config.limit) {
246
+ // Call custom handler if provided
247
+ if (config.handler) {
248
+ await config.handler(ctx, info);
249
+ }
250
+
251
+ // Throw rate limit error
252
+ const retryAfterSeconds = Math.ceil(info.retryAfter / 1000);
253
+ throw new TooManyRequestsError(
254
+ config.message || 'Too many requests, please try again later.',
255
+ retryAfterSeconds,
256
+ );
257
+ }
258
+
259
+ return info;
260
+ }
261
+
262
+ /**
263
+ * Generate rate limit headers
264
+ */
265
+ export function getRateLimitHeaders(
266
+ info: RateLimitInfo,
267
+ config: RateLimitConfig,
268
+ ): RateLimitHeaders {
269
+ const headers: RateLimitHeaders = {};
270
+
271
+ if (config.standardHeaders !== false) {
272
+ headers['X-RateLimit-Limit'] = info.limit.toString();
273
+ headers['X-RateLimit-Remaining'] = info.remaining.toString();
274
+ headers['X-RateLimit-Reset'] = new Date(info.resetTime).toISOString();
275
+ }
276
+
277
+ if (config.legacyHeaders) {
278
+ headers['X-RateLimit-Retry-After'] = info.retryAfter.toString();
279
+ headers['X-RateLimit-Reset-After'] = Math.ceil(
280
+ info.retryAfter / 1000,
281
+ ).toString();
282
+ }
283
+
284
+ // Always set Retry-After when limit is exceeded
285
+ if (info.remaining === 0) {
286
+ headers['Retry-After'] = Math.ceil(info.retryAfter / 1000).toString();
287
+ }
288
+
289
+ return headers;
290
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true
7
+ },
8
+ "include": ["src/**/*"]
9
+ }