@geekmidas/rate-limit 10.0.0-alpha.16 → 10.0.0-alpha.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -499,6 +499,16 @@ const config = {
499
499
  - [@geekmidas/errors](../errors) - HTTP error classes
500
500
  - [@geekmidas/logger](../logger) - Structured logging
501
501
 
502
+ ## Optional peers
503
+
504
+ <!-- optional-peers:start -->
505
+
506
+ <!-- Generated by `node scripts/check-optional-peers.mjs --write-docs`; CI fails when it drifts. -->
507
+
508
+ No entry imports an optional peer when it loads. `@geekmidas/cache`, `@geekmidas/logger`, `@geekmidas/services` are loaded only when a feature that uses one runs (an `import()` inside a `try`, whose error names the package to install), or named only in types.
509
+
510
+ <!-- optional-peers:end -->
511
+
502
512
  ## License
503
513
 
504
514
  MIT
package/dist/index.cjs CHANGED
@@ -1,18 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _geekmidas_errors = require("@geekmidas/errors");
2
3
  //#region src/index.ts
3
4
  /**
4
- * Error thrown when rate limit is exceeded
5
- */
6
- var TooManyRequestsError = class extends Error {
7
- statusCode = 429;
8
- retryAfter;
9
- constructor(message, retryAfter) {
10
- super(message || "Too many requests, please try again later.");
11
- this.name = "TooManyRequestsError";
12
- this.retryAfter = retryAfter;
13
- }
14
- };
15
- /**
16
5
  * Default key generator using IP address
17
6
  */
18
7
  const defaultKeyGenerator = (ctx) => {
@@ -56,7 +45,7 @@ async function checkRateLimit(config, ctx) {
56
45
  if (data.count > config.limit) {
57
46
  if (config.handler) await config.handler(ctx, info);
58
47
  const retryAfterSeconds = Math.ceil(info.retryAfter / 1e3);
59
- throw new TooManyRequestsError(config.message || "Too many requests, please try again later.", retryAfterSeconds);
48
+ throw new _geekmidas_errors.TooManyRequestsError(config.message || "Too many requests, please try again later.", retryAfterSeconds, { headers: definedHeaders(getRateLimitHeaders(info, config)) });
60
49
  }
61
50
  return info;
62
51
  }
@@ -77,8 +66,12 @@ function getRateLimitHeaders(info, config) {
77
66
  if (info.remaining === 0) headers["Retry-After"] = Math.ceil(info.retryAfter / 1e3).toString();
78
67
  return headers;
79
68
  }
69
+ /** The headers that are set, as a plain record. */
70
+ function definedHeaders(headers) {
71
+ return Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== void 0));
72
+ }
80
73
  //#endregion
81
- exports.TooManyRequestsError = TooManyRequestsError;
74
+ exports.TooManyRequestsError = _geekmidas_errors.TooManyRequestsError;
82
75
  exports.checkRateLimit = checkRateLimit;
83
76
  exports.defaultKeyGenerator = defaultKeyGenerator;
84
77
  exports.getRateLimitHeaders = getRateLimitHeaders;
@@ -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":["TooManyRequestsError"],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport { TooManyRequestsError } from '@geekmidas/errors';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Thrown when a client is over its limit: `@geekmidas/errors`' 429, so every\n * endpoint adaptor answers 429 with it — its `Retry-After` and\n * `X-RateLimit-*` headers on the response — rather than a 500.\n */\nexport { TooManyRequestsError };\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// A 429 carrying what the client needs to come back: `Retry-After`,\n\t\t// the seconds until the window resets, and the usual `X-RateLimit-*`.\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\t{ headers: definedHeaders(getRateLimitHeaders(info, config)) },\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\n/** The headers that are set, as a plain record. */\nfunction definedHeaders(headers: RateLimitHeaders): Record<string, string> {\n\treturn Object.fromEntries(\n\t\tObject.entries(headers).filter(\n\t\t\t(entry): entry is [string, string] => entry[1] !== undefined,\n\t\t),\n\t);\n}\n"],"mappings":";;;;;;AAwKA,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;EAK/B,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,GAAI;EAC1D,MAAM,IAAIA,kBAAAA,qBACT,OAAO,WAAW,8CAClB,mBACA,EAAE,SAAS,eAAe,oBAAoB,MAAM,MAAM,CAAC,EAAE,CAC9D;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;;AAGA,SAAS,eAAe,SAAmD;CAC1E,OAAO,OAAO,YACb,OAAO,QAAQ,OAAO,CAAC,CAAC,QACtB,UAAqC,MAAM,OAAO,KAAA,CACpD,CACD;AACD"}
package/dist/index.d.cts CHANGED
@@ -1,15 +1,8 @@
1
1
  import { Cache } from "@geekmidas/cache";
2
+ import { TooManyRequestsError } from "@geekmidas/errors";
2
3
  import { Logger } from "@geekmidas/logger";
3
4
  import { Service, ServiceRecord } from "@geekmidas/services";
4
5
  //#region src/index.d.ts
5
- /**
6
- * Error thrown when rate limit is exceeded
7
- */
8
- export declare class TooManyRequestsError extends Error {
9
- readonly statusCode = 429;
10
- readonly retryAfter?: number;
11
- constructor(message?: string, retryAfter?: number);
12
- }
13
6
  /**
14
7
  * Rate limit configuration for an endpoint
15
8
  */
@@ -133,4 +126,5 @@ export declare function checkRateLimit<TServices extends Service[] = [], TLogger
133
126
  */
134
127
  export declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
135
128
  //#endregion
129
+ export { TooManyRequestsError };
136
130
  //# 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"],"mappings":";;;;;;;;iBAeiB;;;;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;;;;wBAsEK,oBACf,MAAM,eACN,QAAQ,kBACN"}
package/dist/index.d.mts CHANGED
@@ -1,15 +1,8 @@
1
+ import { TooManyRequestsError } from "@geekmidas/errors";
1
2
  import { Cache } from "@geekmidas/cache";
2
3
  import { Logger } from "@geekmidas/logger";
3
4
  import { Service, ServiceRecord } from "@geekmidas/services";
4
5
  //#region src/index.d.ts
5
- /**
6
- * Error thrown when rate limit is exceeded
7
- */
8
- export declare class TooManyRequestsError extends Error {
9
- readonly statusCode = 429;
10
- readonly retryAfter?: number;
11
- constructor(message?: string, retryAfter?: number);
12
- }
13
6
  /**
14
7
  * Rate limit configuration for an endpoint
15
8
  */
@@ -133,4 +126,5 @@ export declare function checkRateLimit<TServices extends Service[] = [], TLogger
133
126
  */
134
127
  export declare function getRateLimitHeaders(info: RateLimitInfo, config: RateLimitConfig): RateLimitHeaders;
135
128
  //#endregion
129
+ export { TooManyRequestsError };
136
130
  //# 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"],"mappings":";;;;;;;;iBAeiB;;;;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;;;;wBAsEK,oBACf,MAAM,eACN,QAAQ,kBACN"}
package/dist/index.mjs CHANGED
@@ -1,17 +1,6 @@
1
+ import { TooManyRequestsError } from "@geekmidas/errors";
1
2
  //#region src/index.ts
2
3
  /**
3
- * Error thrown when rate limit is exceeded
4
- */
5
- var TooManyRequestsError = class extends Error {
6
- statusCode = 429;
7
- retryAfter;
8
- constructor(message, retryAfter) {
9
- super(message || "Too many requests, please try again later.");
10
- this.name = "TooManyRequestsError";
11
- this.retryAfter = retryAfter;
12
- }
13
- };
14
- /**
15
4
  * Default key generator using IP address
16
5
  */
17
6
  const defaultKeyGenerator = (ctx) => {
@@ -55,7 +44,7 @@ async function checkRateLimit(config, ctx) {
55
44
  if (data.count > config.limit) {
56
45
  if (config.handler) await config.handler(ctx, info);
57
46
  const retryAfterSeconds = Math.ceil(info.retryAfter / 1e3);
58
- throw new TooManyRequestsError(config.message || "Too many requests, please try again later.", retryAfterSeconds);
47
+ throw new TooManyRequestsError(config.message || "Too many requests, please try again later.", retryAfterSeconds, { headers: definedHeaders(getRateLimitHeaders(info, config)) });
59
48
  }
60
49
  return info;
61
50
  }
@@ -76,6 +65,10 @@ function getRateLimitHeaders(info, config) {
76
65
  if (info.remaining === 0) headers["Retry-After"] = Math.ceil(info.retryAfter / 1e3).toString();
77
66
  return headers;
78
67
  }
68
+ /** The headers that are set, as a plain record. */
69
+ function definedHeaders(headers) {
70
+ return Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== void 0));
71
+ }
79
72
  //#endregion
80
73
  export { TooManyRequestsError, checkRateLimit, defaultKeyGenerator, getRateLimitHeaders };
81
74
 
@@ -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":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Cache } from '@geekmidas/cache';\nimport { TooManyRequestsError } from '@geekmidas/errors';\nimport type { Logger } from '@geekmidas/logger';\nimport type { Service, ServiceRecord } from '@geekmidas/services';\n\n/**\n * Thrown when a client is over its limit: `@geekmidas/errors`' 429, so every\n * endpoint adaptor answers 429 with it — its `Retry-After` and\n * `X-RateLimit-*` headers on the response — rather than a 500.\n */\nexport { TooManyRequestsError };\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// A 429 carrying what the client needs to come back: `Retry-After`,\n\t\t// the seconds until the window resets, and the usual `X-RateLimit-*`.\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\t{ headers: definedHeaders(getRateLimitHeaders(info, config)) },\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\n/** The headers that are set, as a plain record. */\nfunction definedHeaders(headers: RateLimitHeaders): Record<string, string> {\n\treturn Object.fromEntries(\n\t\tObject.entries(headers).filter(\n\t\t\t(entry): entry is [string, string] => entry[1] !== undefined,\n\t\t),\n\t);\n}\n"],"mappings":";;;;;AAwKA,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;EAK/B,MAAM,oBAAoB,KAAK,KAAK,KAAK,aAAa,GAAI;EAC1D,MAAM,IAAI,qBACT,OAAO,WAAW,8CAClB,mBACA,EAAE,SAAS,eAAe,oBAAoB,MAAM,MAAM,CAAC,EAAE,CAC9D;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;;AAGA,SAAS,eAAe,SAAmD;CAC1E,OAAO,OAAO,YACb,OAAO,QAAQ,OAAO,CAAC,CAAC,QACtB,UAAqC,MAAM,OAAO,KAAA,CACpD,CACD;AACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/rate-limit",
3
- "version": "10.0.0-alpha.16",
3
+ "version": "10.0.0-alpha.161",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,11 +26,13 @@
26
26
  "registry": "https://registry.npmjs.org/",
27
27
  "access": "public"
28
28
  },
29
- "dependencies": {},
29
+ "dependencies": {
30
+ "@geekmidas/errors": ">=10.0.0-alpha.161 <10.0.0"
31
+ },
30
32
  "peerDependencies": {
31
- "@geekmidas/cache": "^10.0.0-alpha.16",
32
- "@geekmidas/logger": "^10.0.0-alpha.16",
33
- "@geekmidas/services": "^10.0.0-alpha.16"
33
+ "@geekmidas/cache": ">=10.0.0-alpha.161 <10.0.0",
34
+ "@geekmidas/logger": ">=10.0.0-alpha.161 <10.0.0",
35
+ "@geekmidas/services": ">=10.0.0-alpha.161 <10.0.0"
34
36
  },
35
37
  "peerDependenciesMeta": {
36
38
  "@geekmidas/cache": {
@@ -44,11 +46,12 @@
44
46
  }
45
47
  },
46
48
  "devDependencies": {
47
- "@geekmidas/cache": "^10.0.0-alpha.16",
48
- "@geekmidas/logger": "^10.0.0-alpha.16",
49
- "@geekmidas/services": "^10.0.0-alpha.16"
49
+ "@geekmidas/cache": ">=10.0.0-alpha.161 <10.0.0",
50
+ "@geekmidas/logger": ">=10.0.0-alpha.161 <10.0.0",
51
+ "@geekmidas/services": ">=10.0.0-alpha.161 <10.0.0"
50
52
  },
51
53
  "scripts": {
52
- "ts": "tsc --noEmit --skipLibCheck src/**/*.ts"
54
+ "ts": "tsc --noEmit --skipLibCheck src/**/*.ts",
55
+ "build": "tsdown --config ../../tsdown.config.ts --filter ."
53
56
  }
54
57
  }