@ttt-productions/rate-limit-core 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,13 @@
1
1
  export interface RateLimitErrorMessageOptions {
2
- /** Epoch ms when the limit resets (returned by `checkRateLimit`). */
3
2
  reset: number;
4
- /** Optional context shown in the message (e.g. "Upload"). */
5
3
  context?: string;
6
- /** Optional copy overrides — generic defaults are used when omitted. */
7
4
  copy?: {
8
- /** Suffix appended to every message. Default: "" (none). */
9
5
  suffix?: string;
10
- /** Template builder. Receives the formatted time string and context. */
11
6
  format?: (args: {
12
7
  timeString: string;
13
8
  context?: string;
14
9
  }) => string;
15
10
  };
16
11
  }
17
- /**
18
- * Generate a user-friendly rate-limit error message with relative time.
19
- * Copy is fully consumer-controlled — the consuming app supplies any
20
- * additional suffix (e.g. an "Early-launch rate limits in place" notice)
21
- * at the call site.
22
- */
23
12
  export declare function getRateLimitErrorMessage(opts: RateLimitErrorMessageOptions): string;
24
13
  //# sourceMappingURL=error-message.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error-message.d.ts","sourceRoot":"","sources":["../src/error-message.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,IAAI,CAAC,EAAE;QACL,4DAA4D;QAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,wEAAwE;QACxE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,MAAM,CAAC;KACrE,CAAC;CACH;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,4BAA4B,GAAG,MAAM,CAcnF"}
1
+ {"version":3,"file":"error-message.d.ts","sourceRoot":"","sources":["../src/error-message.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE;QACL,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,KAAK,MAAM,CAAC;KACrE,CAAC;CACH;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,4BAA4B,GAAG,MAAM,CAcnF"}
@@ -1,9 +1,3 @@
1
- /**
2
- * Generate a user-friendly rate-limit error message with relative time.
3
- * Copy is fully consumer-controlled — the consuming app supplies any
4
- * additional suffix (e.g. an "Early-launch rate limits in place" notice)
5
- * at the call site.
6
- */
7
1
  export function getRateLimitErrorMessage(opts) {
8
2
  const { reset, context, copy } = opts;
9
3
  const minutes = Math.max(1, Math.ceil((reset - Date.now()) / 60_000));
@@ -1 +1 @@
1
- {"version":3,"file":"error-message.js","sourceRoot":"","sources":["../src/error-message.ts"],"names":[],"mappings":"AAcA;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,IAAkC;IACzE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC;IACtE,MAAM,UAAU,GAAG,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,UAAU,CAAC;IAErE,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,IAAI,GAAG,OAAO;QAClB,CAAC,CAAC,GAAG,OAAO,sCAAsC,UAAU,GAAG;QAC/D,CAAC,CAAC,qCAAqC,UAAU,GAAG,CAAC;IAEvD,OAAO,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACxD,CAAC","sourcesContent":["export interface RateLimitErrorMessageOptions {\n /** Epoch ms when the limit resets (returned by `checkRateLimit`). */\n reset: number;\n /** Optional context shown in the message (e.g. \"Upload\"). */\n context?: string;\n /** Optional copy overrides — generic defaults are used when omitted. */\n copy?: {\n /** Suffix appended to every message. Default: \"\" (none). */\n suffix?: string;\n /** Template builder. Receives the formatted time string and context. */\n format?: (args: { timeString: string; context?: string }) => string;\n };\n}\n\n/**\n * Generate a user-friendly rate-limit error message with relative time.\n * Copy is fully consumer-controlled — the consuming app supplies any\n * additional suffix (e.g. an \"Early-launch rate limits in place\" notice)\n * at the call site.\n */\nexport function getRateLimitErrorMessage(opts: RateLimitErrorMessageOptions): string {\n const { reset, context, copy } = opts;\n const minutes = Math.max(1, Math.ceil((reset - Date.now()) / 60_000));\n const timeString = minutes === 1 ? '1 minute' : `${minutes} minutes`;\n\n if (copy?.format) {\n return copy.format({ timeString, context });\n }\n\n const base = context\n ? `${context} rate limit exceeded. Try again in ${timeString}.`\n : `Rate limit exceeded. Try again in ${timeString}.`;\n\n return copy?.suffix ? `${base} ${copy.suffix}` : base;\n}\n"]}
1
+ {"version":3,"file":"error-message.js","sourceRoot":"","sources":["../src/error-message.ts"],"names":[],"mappings":"AASA,MAAM,UAAU,wBAAwB,CAAC,IAAkC;IACzE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC;IACtE,MAAM,UAAU,GAAG,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,UAAU,CAAC;IAErE,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,IAAI,GAAG,OAAO;QAClB,CAAC,CAAC,GAAG,OAAO,sCAAsC,UAAU,GAAG;QAC/D,CAAC,CAAC,qCAAqC,UAAU,GAAG,CAAC;IAEvD,OAAO,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACxD,CAAC","sourcesContent":["export interface RateLimitErrorMessageOptions {\n reset: number;\n context?: string;\n copy?: {\n suffix?: string;\n format?: (args: { timeString: string; context?: string }) => string;\n };\n}\n\nexport function getRateLimitErrorMessage(opts: RateLimitErrorMessageOptions): string {\n const { reset, context, copy } = opts;\n const minutes = Math.max(1, Math.ceil((reset - Date.now()) / 60_000));\n const timeString = minutes === 1 ? '1 minute' : `${minutes} minutes`;\n\n if (copy?.format) {\n return copy.format({ timeString, context });\n }\n\n const base = context\n ? `${context} rate limit exceeded. Try again in ${timeString}.`\n : `Rate limit exceeded. Try again in ${timeString}.`;\n\n return copy?.suffix ? `${base} ${copy.suffix}` : base;\n}\n"]}
@@ -1,53 +1,20 @@
1
1
  import { Ratelimit } from '@upstash/ratelimit';
2
2
  import type { Redis } from '@upstash/redis';
3
3
  export interface RateLimitConfig {
4
- /** Unique prefix for this limiter (e.g. "ratelimit:upload"). */
5
4
  prefix: string;
6
- /** Max requests allowed in the window. */
7
5
  maxRequests: number;
8
- /** Window duration (e.g. "1 h", "10 m", "1 d"). */
9
6
  window: string;
10
7
  }
11
- /**
12
- * Structured report of a rate-limiter degradation. Passed to the injected
13
- * `onDegraded` hook so the consuming app can decide the alerting posture
14
- * (e.g. Sentry `captureMessage` in prod, a warn line in the emulator) WITHOUT
15
- * this generic package importing a monitoring provider directly.
16
- *
17
- * - `reason: 'unavailable'` — a limiter could not be built (Redis is null:
18
- * missing credentials or the disable hook). Fired at most once per factory
19
- * instance, on the FIRST `checkRateLimit` call, so an unconfigured prod deploy
20
- * pages within minutes of traffic instead of silently allowing everything.
21
- * - `reason: 'limit-error'` — `limiter.limit()` rejected at runtime (e.g. an
22
- * Upstash outage). The call fails OPEN (`allowed: true`) after reporting.
23
- */
24
8
  export interface RateLimiterDegradation {
25
9
  reason: 'unavailable' | 'limit-error';
26
- /** The limiter prefix in play, when known. */
27
10
  prefix?: string;
28
- /** The underlying error for `reason: 'limit-error'`. */
29
11
  error?: unknown;
30
12
  }
31
13
  export interface RateLimiterFactoryOptions {
32
- /** Returns the Redis client or null when disabled. */
33
14
  getRedis: () => Redis | null;
34
- /** When true, includes Upstash analytics. Default true. */
35
15
  analytics?: boolean;
36
- /**
37
- * Optional degradation hook. The consuming app injects its own alerting
38
- * (Sentry capture in prod, warn in local/emulator) — this package stays
39
- * provider-agnostic. `unavailable` fires at most once per factory instance;
40
- * `limit-error` fires per failed `limit()` call. The hook must never throw
41
- * (it is invoked inside the fail-open path).
42
- */
43
16
  onDegraded?: (info: RateLimiterDegradation) => void;
44
17
  }
45
- /**
46
- * Create a memoized Ratelimit factory. One limiter is kept per `prefix`, and it is
47
- * reused only while `maxRequests` and `window` still match: a caller that varies
48
- * the capacity at runtime (a live incident multiplier) gets a limiter built for the
49
- * new values, counting in the same Redis prefix, instead of the one built first.
50
- */
51
18
  export declare function createRateLimiterFactory(options: RateLimiterFactoryOptions): {
52
19
  getRateLimiter: (config: RateLimitConfig) => Ratelimit | null;
53
20
  checkRateLimit: (key: string, config: RateLimitConfig) => Promise<{
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,GAAG,aAAa,CAAC;IACtC,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wDAAwD;IACxD,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,sDAAsD;IACtD,QAAQ,EAAE,MAAM,KAAK,GAAG,IAAI,CAAC;IAC7B,2DAA2D;IAC3D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,IAAI,CAAC;CACrD;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB;6BAczC,eAAe,KAAG,SAAS,GAAG,IAAI;0BA0C3D,MAAM,UACH,eAAe,KACtB,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;EAqBlE"}
1
+ {"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,GAAG,aAAa,CAAC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,MAAM,KAAK,GAAG,IAAI,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,IAAI,CAAC;CACrD;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB;6BAWzC,eAAe,KAAG,SAAS,GAAG,IAAI;0BA+B3D,MAAM,UACH,eAAe,KACtB,OAAO,CAAC;QAAE,OAAO,EAAE,IAAI,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;EAoBlE"}
@@ -1,22 +1,13 @@
1
1
  import { Ratelimit } from '@upstash/ratelimit';
2
- /**
3
- * Create a memoized Ratelimit factory. One limiter is kept per `prefix`, and it is
4
- * reused only while `maxRequests` and `window` still match: a caller that varies
5
- * the capacity at runtime (a live incident multiplier) gets a limiter built for the
6
- * new values, counting in the same Redis prefix, instead of the one built first.
7
- */
8
2
  export function createRateLimiterFactory(options) {
9
3
  const limiters = new Map();
10
4
  const analytics = options.analytics ?? true;
11
- // Fire the `unavailable` degradation at most once per factory instance.
12
5
  let reportedUnavailable = false;
13
6
  function report(info) {
14
7
  try {
15
8
  options.onDegraded?.(info);
16
9
  }
17
- catch {
18
- // The alerting hook must never break the fail-open path.
19
- }
10
+ catch { }
20
11
  }
21
12
  function getRateLimiter(config) {
22
13
  const redis = options.getRedis();
@@ -41,17 +32,6 @@ export function createRateLimiterFactory(options) {
41
32
  });
42
33
  return limiter;
43
34
  }
44
- /**
45
- * Check rate limit for a key (e.g. userId). Returns `{ allowed: true }`
46
- * when Redis is unavailable OR the limit isn't exceeded.
47
- *
48
- * FAIL-OPEN POSTURE (decided 2026-07-03): both failure modes degrade to
49
- * "allow, but observed" rather than blocking every write. When the limiter is
50
- * unavailable (Redis null — missing credentials / disabled), the first call
51
- * reports `unavailable` so an unconfigured prod deploy is loud. When a built
52
- * limiter's `limit()` rejects (Upstash outage), the error is reported and the
53
- * call still allows — an outage must not take down every rate-limited callable.
54
- */
55
35
  async function checkRateLimit(key, config) {
56
36
  const limiter = getRateLimiter(config);
57
37
  if (!limiter) {
@@ -68,7 +48,6 @@ export function createRateLimiterFactory(options) {
68
48
  return { allowed: true };
69
49
  }
70
50
  catch (error) {
71
- // Runtime Redis error (e.g. Upstash outage). Fail OPEN, but report.
72
51
  report({ reason: 'limit-error', prefix: config.prefix, error });
73
52
  return { allowed: true };
74
53
  }
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAgD/C;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,OAAkC;IACzE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAuE,CAAC;IAChG,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC;IAC5C,wEAAwE;IACxE,IAAI,mBAAmB,GAAG,KAAK,CAAC;IAEhC,SAAS,MAAM,CAAC,IAA4B;QAC1C,IAAI,CAAC;YACH,OAAO,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,CAAC;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,yDAAyD;QAC3D,CAAC;IACH,CAAC;IAED,SAAS,cAAc,CAAC,MAAuB;QAC7C,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;QACjC,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QAExB,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC7C,IACE,QAAQ;YACR,QAAQ,CAAC,WAAW,KAAK,MAAM,CAAC,WAAW;YAC3C,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,EACjC,CAAC;YACD,OAAO,QAAQ,CAAC,OAAO,CAAC;QAC1B,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,SAAS,CAAC;YAC5B,KAAK;YACL,OAAO,EAAE,SAAS,CAAC,aAAa,CAC9B,MAAM,CAAC,WAAW,EAClB,MAAM,CAAC,MAAuD,CAC/D;YACD,SAAS;YACT,MAAM,EAAE,MAAM,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE;YAC1B,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,OAAO;SACR,CAAC,CAAC;QACH,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;;;;;;;;OAUG;IACH,KAAK,UAAU,cAAc,CAC3B,GAAW,EACX,MAAuB;QAEvB,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;QACvC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,IAAI,CAAC,mBAAmB,EAAE,CAAC;gBACzB,mBAAmB,GAAG,IAAI,CAAC;gBAC3B,MAAM,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;YAC3D,CAAC;YACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;QACD,IAAI,CAAC;YACH,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACpD,IAAI,CAAC,OAAO;gBAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;YAC/C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,oEAAoE;YACpE,MAAM,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;YAChE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;IACH,CAAC;IAED,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,CAAC;AAC5C,CAAC","sourcesContent":["import { Ratelimit } from '@upstash/ratelimit';\nimport type { Redis } from '@upstash/redis';\n\nexport interface RateLimitConfig {\n /** Unique prefix for this limiter (e.g. \"ratelimit:upload\"). */\n prefix: string;\n /** Max requests allowed in the window. */\n maxRequests: number;\n /** Window duration (e.g. \"1 h\", \"10 m\", \"1 d\"). */\n window: string;\n}\n\n/**\n * Structured report of a rate-limiter degradation. Passed to the injected\n * `onDegraded` hook so the consuming app can decide the alerting posture\n * (e.g. Sentry `captureMessage` in prod, a warn line in the emulator) WITHOUT\n * this generic package importing a monitoring provider directly.\n *\n * - `reason: 'unavailable'` — a limiter could not be built (Redis is null:\n * missing credentials or the disable hook). Fired at most once per factory\n * instance, on the FIRST `checkRateLimit` call, so an unconfigured prod deploy\n * pages within minutes of traffic instead of silently allowing everything.\n * - `reason: 'limit-error'` — `limiter.limit()` rejected at runtime (e.g. an\n * Upstash outage). The call fails OPEN (`allowed: true`) after reporting.\n */\nexport interface RateLimiterDegradation {\n reason: 'unavailable' | 'limit-error';\n /** The limiter prefix in play, when known. */\n prefix?: string;\n /** The underlying error for `reason: 'limit-error'`. */\n error?: unknown;\n}\n\nexport interface RateLimiterFactoryOptions {\n /** Returns the Redis client or null when disabled. */\n getRedis: () => Redis | null;\n /** When true, includes Upstash analytics. Default true. */\n analytics?: boolean;\n /**\n * Optional degradation hook. The consuming app injects its own alerting\n * (Sentry capture in prod, warn in local/emulator) — this package stays\n * provider-agnostic. `unavailable` fires at most once per factory instance;\n * `limit-error` fires per failed `limit()` call. The hook must never throw\n * (it is invoked inside the fail-open path).\n */\n onDegraded?: (info: RateLimiterDegradation) => void;\n}\n\n/**\n * Create a memoized Ratelimit factory. One limiter is kept per `prefix`, and it is\n * reused only while `maxRequests` and `window` still match: a caller that varies\n * the capacity at runtime (a live incident multiplier) gets a limiter built for the\n * new values, counting in the same Redis prefix, instead of the one built first.\n */\nexport function createRateLimiterFactory(options: RateLimiterFactoryOptions) {\n const limiters = new Map<string, { maxRequests: number; window: string; limiter: Ratelimit }>();\n const analytics = options.analytics ?? true;\n // Fire the `unavailable` degradation at most once per factory instance.\n let reportedUnavailable = false;\n\n function report(info: RateLimiterDegradation): void {\n try {\n options.onDegraded?.(info);\n } catch {\n // The alerting hook must never break the fail-open path.\n }\n }\n\n function getRateLimiter(config: RateLimitConfig): Ratelimit | null {\n const redis = options.getRedis();\n if (!redis) return null;\n\n const existing = limiters.get(config.prefix);\n if (\n existing &&\n existing.maxRequests === config.maxRequests &&\n existing.window === config.window\n ) {\n return existing.limiter;\n }\n\n const limiter = new Ratelimit({\n redis,\n limiter: Ratelimit.slidingWindow(\n config.maxRequests,\n config.window as Parameters<typeof Ratelimit.slidingWindow>[1],\n ),\n analytics,\n prefix: config.prefix,\n });\n limiters.set(config.prefix, {\n maxRequests: config.maxRequests,\n window: config.window,\n limiter,\n });\n return limiter;\n }\n\n /**\n * Check rate limit for a key (e.g. userId). Returns `{ allowed: true }`\n * when Redis is unavailable OR the limit isn't exceeded.\n *\n * FAIL-OPEN POSTURE (decided 2026-07-03): both failure modes degrade to\n * \"allow, but observed\" rather than blocking every write. When the limiter is\n * unavailable (Redis null — missing credentials / disabled), the first call\n * reports `unavailable` so an unconfigured prod deploy is loud. When a built\n * limiter's `limit()` rejects (Upstash outage), the error is reported and the\n * call still allows — an outage must not take down every rate-limited callable.\n */\n async function checkRateLimit(\n key: string,\n config: RateLimitConfig,\n ): Promise<{ allowed: true } | { allowed: false; reset: number }> {\n const limiter = getRateLimiter(config);\n if (!limiter) {\n if (!reportedUnavailable) {\n reportedUnavailable = true;\n report({ reason: 'unavailable', prefix: config.prefix });\n }\n return { allowed: true };\n }\n try {\n const { success, reset } = await limiter.limit(key);\n if (!success) return { allowed: false, reset };\n return { allowed: true };\n } catch (error) {\n // Runtime Redis error (e.g. Upstash outage). Fail OPEN, but report.\n report({ reason: 'limit-error', prefix: config.prefix, error });\n return { allowed: true };\n }\n }\n\n return { getRateLimiter, checkRateLimit };\n}\n"]}
1
+ {"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAqB/C,MAAM,UAAU,wBAAwB,CAAC,OAAkC;IACzE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAuE,CAAC;IAChG,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC;IAC5C,IAAI,mBAAmB,GAAG,KAAK,CAAC;IAEhC,SAAS,MAAM,CAAC,IAA4B;QAC1C,IAAI,CAAC;YACH,OAAO,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,CAAC;QAC7B,CAAC;QAAC,MAAM,CAAC,CAAA,CAAC;IACZ,CAAC;IAED,SAAS,cAAc,CAAC,MAAuB;QAC7C,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;QACjC,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QAExB,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC7C,IACE,QAAQ;YACR,QAAQ,CAAC,WAAW,KAAK,MAAM,CAAC,WAAW;YAC3C,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,EACjC,CAAC;YACD,OAAO,QAAQ,CAAC,OAAO,CAAC;QAC1B,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,SAAS,CAAC;YAC5B,KAAK;YACL,OAAO,EAAE,SAAS,CAAC,aAAa,CAC9B,MAAM,CAAC,WAAW,EAClB,MAAM,CAAC,MAAuD,CAC/D;YACD,SAAS;YACT,MAAM,EAAE,MAAM,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE;YAC1B,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,OAAO;SACR,CAAC,CAAC;QACH,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,KAAK,UAAU,cAAc,CAC3B,GAAW,EACX,MAAuB;QAEvB,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;QACvC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,IAAI,CAAC,mBAAmB,EAAE,CAAC;gBACzB,mBAAmB,GAAG,IAAI,CAAC;gBAC3B,MAAM,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;YAC3D,CAAC;YACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;QACD,IAAI,CAAC;YACH,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACpD,IAAI,CAAC,OAAO;gBAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;YAC/C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;YAChE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;IACH,CAAC;IAED,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,CAAC;AAC5C,CAAC","sourcesContent":["import { Ratelimit } from '@upstash/ratelimit';\nimport type { Redis } from '@upstash/redis';\n\nexport interface RateLimitConfig {\n prefix: string;\n maxRequests: number;\n window: string;\n}\n\nexport interface RateLimiterDegradation {\n reason: 'unavailable' | 'limit-error';\n prefix?: string;\n error?: unknown;\n}\n\nexport interface RateLimiterFactoryOptions {\n getRedis: () => Redis | null;\n analytics?: boolean;\n onDegraded?: (info: RateLimiterDegradation) => void;\n}\n\nexport function createRateLimiterFactory(options: RateLimiterFactoryOptions) {\n const limiters = new Map<string, { maxRequests: number; window: string; limiter: Ratelimit }>();\n const analytics = options.analytics ?? true;\n let reportedUnavailable = false;\n\n function report(info: RateLimiterDegradation): void {\n try {\n options.onDegraded?.(info);\n } catch {}\n }\n\n function getRateLimiter(config: RateLimitConfig): Ratelimit | null {\n const redis = options.getRedis();\n if (!redis) return null;\n\n const existing = limiters.get(config.prefix);\n if (\n existing &&\n existing.maxRequests === config.maxRequests &&\n existing.window === config.window\n ) {\n return existing.limiter;\n }\n\n const limiter = new Ratelimit({\n redis,\n limiter: Ratelimit.slidingWindow(\n config.maxRequests,\n config.window as Parameters<typeof Ratelimit.slidingWindow>[1],\n ),\n analytics,\n prefix: config.prefix,\n });\n limiters.set(config.prefix, {\n maxRequests: config.maxRequests,\n window: config.window,\n limiter,\n });\n return limiter;\n }\n\n async function checkRateLimit(\n key: string,\n config: RateLimitConfig,\n ): Promise<{ allowed: true } | { allowed: false; reset: number }> {\n const limiter = getRateLimiter(config);\n if (!limiter) {\n if (!reportedUnavailable) {\n reportedUnavailable = true;\n report({ reason: 'unavailable', prefix: config.prefix });\n }\n return { allowed: true };\n }\n try {\n const { success, reset } = await limiter.limit(key);\n if (!success) return { allowed: false, reset };\n return { allowed: true };\n } catch (error) {\n report({ reason: 'limit-error', prefix: config.prefix, error });\n return { allowed: true };\n }\n }\n\n return { getRateLimiter, checkRateLimit };\n}\n"]}
@@ -1,28 +1,18 @@
1
1
  import { Redis } from '@upstash/redis';
2
2
  export interface RedisCredentialsProvider {
3
- /** Lazy URL resolver — invoked only when Redis is first needed. */
4
3
  url: () => string | undefined;
5
- /** Lazy token resolver — invoked only when Redis is first needed. */
6
4
  token: () => string | undefined;
7
5
  }
8
6
  export interface RedisClientFactoryOptions {
9
7
  credentials: RedisCredentialsProvider;
10
- /** Optional disable hook (e.g. emulator/local-dev detection). When this returns true on first call, Redis is permanently disabled for this factory instance. */
11
8
  disabledWhen?: () => boolean;
12
- /** Optional logger for "Redis not configured" / "disabled" diagnostics. */
13
9
  logger?: {
14
10
  info?: (message: string) => void;
15
11
  warn?: (message: string) => void;
16
12
  };
17
13
  }
18
- /**
19
- * Build a lazy, singleton Redis client. The client is constructed on first
20
- * `get()` and reused thereafter. Returns null when credentials are missing
21
- * or the disable hook returned true at first access.
22
- */
23
14
  export declare function createRedisClientFactory(options: RedisClientFactoryOptions): {
24
15
  get(): Redis | null;
25
- /** Test-only: reset cached state. Do not call in production. */
26
16
  __reset(): void;
27
17
  };
28
18
  //# sourceMappingURL=redis-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"redis-client.d.ts","sourceRoot":"","sources":["../src/redis-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAEvC,MAAM,WAAW,wBAAwB;IACvC,mEAAmE;IACnE,GAAG,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAC9B,qEAAqE;IACrE,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,wBAAwB,CAAC;IACtC,gKAAgK;IAChK,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC;IAC7B,2EAA2E;IAC3E,MAAM,CAAC,EAAE;QACP,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;QACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;KAClC,CAAC;CACH;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG;IAC5E,GAAG,IAAI,KAAK,GAAG,IAAI,CAAC;IACpB,gEAAgE;IAChE,OAAO,IAAI,IAAI,CAAC;CACjB,CAoCA"}
1
+ {"version":3,"file":"redis-client.d.ts","sourceRoot":"","sources":["../src/redis-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAEvC,MAAM,WAAW,wBAAwB;IACvC,GAAG,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAC9B,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,wBAAwB,CAAC;IACtC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC;IAC7B,MAAM,CAAC,EAAE;QACP,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;QACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;KAClC,CAAC;CACH;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG;IAC5E,GAAG,IAAI,KAAK,GAAG,IAAI,CAAC;IACpB,OAAO,IAAI,IAAI,CAAC;CACjB,CAoCA"}
@@ -1,9 +1,4 @@
1
1
  import { Redis } from '@upstash/redis';
2
- /**
3
- * Build a lazy, singleton Redis client. The client is constructed on first
4
- * `get()` and reused thereafter. Returns null when credentials are missing
5
- * or the disable hook returned true at first access.
6
- */
7
2
  export function createRedisClientFactory(options) {
8
3
  let cached = null;
9
4
  let disabled = false;
@@ -1 +1 @@
1
- {"version":3,"file":"redis-client.js","sourceRoot":"","sources":["../src/redis-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAoBvC;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,OAAkC;IAKzE,IAAI,MAAM,GAAiB,IAAI,CAAC;IAChC,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,SAAS,OAAO;QACd,IAAI,QAAQ;YAAE,OAAO,MAAM,CAAC;QAC5B,QAAQ,GAAG,IAAI,CAAC;QAEhB,IAAI,OAAO,CAAC,YAAY,EAAE,EAAE,EAAE,CAAC;YAC7B,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,wDAAwD,CAAC,CAAC;YACjF,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC;QACtC,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;QAC1C,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;YACnB,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,qDAAqD,CAAC,CAAC;YAC9E,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,GAAG,IAAI,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;QACnC,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,OAAO;QACL,GAAG;YACD,IAAI,QAAQ;gBAAE,OAAO,IAAI,CAAC;YAC1B,OAAO,OAAO,EAAE,CAAC;QACnB,CAAC;QACD,OAAO;YACL,MAAM,GAAG,IAAI,CAAC;YACd,QAAQ,GAAG,KAAK,CAAC;YACjB,QAAQ,GAAG,KAAK,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import { Redis } from '@upstash/redis';\n\nexport interface RedisCredentialsProvider {\n /** Lazy URL resolver — invoked only when Redis is first needed. */\n url: () => string | undefined;\n /** Lazy token resolver — invoked only when Redis is first needed. */\n token: () => string | undefined;\n}\n\nexport interface RedisClientFactoryOptions {\n credentials: RedisCredentialsProvider;\n /** Optional disable hook (e.g. emulator/local-dev detection). When this returns true on first call, Redis is permanently disabled for this factory instance. */\n disabledWhen?: () => boolean;\n /** Optional logger for \"Redis not configured\" / \"disabled\" diagnostics. */\n logger?: {\n info?: (message: string) => void;\n warn?: (message: string) => void;\n };\n}\n\n/**\n * Build a lazy, singleton Redis client. The client is constructed on first\n * `get()` and reused thereafter. Returns null when credentials are missing\n * or the disable hook returned true at first access.\n */\nexport function createRedisClientFactory(options: RedisClientFactoryOptions): {\n get(): Redis | null;\n /** Test-only: reset cached state. Do not call in production. */\n __reset(): void;\n} {\n let cached: Redis | null = null;\n let disabled = false;\n let resolved = false;\n\n function resolve(): Redis | null {\n if (resolved) return cached;\n resolved = true;\n\n if (options.disabledWhen?.()) {\n options.logger?.info?.('[rate-limit-core] Redis disabled by disabledWhen hook.');\n disabled = true;\n return null;\n }\n const url = options.credentials.url();\n const token = options.credentials.token();\n if (!url || !token) {\n options.logger?.warn?.('[rate-limit-core] Redis credentials not configured.');\n disabled = true;\n return null;\n }\n cached = new Redis({ url, token });\n return cached;\n }\n\n return {\n get() {\n if (disabled) return null;\n return resolve();\n },\n __reset() {\n cached = null;\n disabled = false;\n resolved = false;\n },\n };\n}\n"]}
1
+ {"version":3,"file":"redis-client.js","sourceRoot":"","sources":["../src/redis-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAgBvC,MAAM,UAAU,wBAAwB,CAAC,OAAkC;IAIzE,IAAI,MAAM,GAAiB,IAAI,CAAC;IAChC,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,SAAS,OAAO;QACd,IAAI,QAAQ;YAAE,OAAO,MAAM,CAAC;QAC5B,QAAQ,GAAG,IAAI,CAAC;QAEhB,IAAI,OAAO,CAAC,YAAY,EAAE,EAAE,EAAE,CAAC;YAC7B,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,wDAAwD,CAAC,CAAC;YACjF,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC;QACtC,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;QAC1C,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;YACnB,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,qDAAqD,CAAC,CAAC;YAC9E,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,GAAG,IAAI,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;QACnC,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,OAAO;QACL,GAAG;YACD,IAAI,QAAQ;gBAAE,OAAO,IAAI,CAAC;YAC1B,OAAO,OAAO,EAAE,CAAC;QACnB,CAAC;QACD,OAAO;YACL,MAAM,GAAG,IAAI,CAAC;YACd,QAAQ,GAAG,KAAK,CAAC;YACjB,QAAQ,GAAG,KAAK,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import { Redis } from '@upstash/redis';\n\nexport interface RedisCredentialsProvider {\n url: () => string | undefined;\n token: () => string | undefined;\n}\n\nexport interface RedisClientFactoryOptions {\n credentials: RedisCredentialsProvider;\n disabledWhen?: () => boolean;\n logger?: {\n info?: (message: string) => void;\n warn?: (message: string) => void;\n };\n}\n\nexport function createRedisClientFactory(options: RedisClientFactoryOptions): {\n get(): Redis | null;\n __reset(): void;\n} {\n let cached: Redis | null = null;\n let disabled = false;\n let resolved = false;\n\n function resolve(): Redis | null {\n if (resolved) return cached;\n resolved = true;\n\n if (options.disabledWhen?.()) {\n options.logger?.info?.('[rate-limit-core] Redis disabled by disabledWhen hook.');\n disabled = true;\n return null;\n }\n const url = options.credentials.url();\n const token = options.credentials.token();\n if (!url || !token) {\n options.logger?.warn?.('[rate-limit-core] Redis credentials not configured.');\n disabled = true;\n return null;\n }\n cached = new Redis({ url, token });\n return cached;\n }\n\n return {\n get() {\n if (disabled) return null;\n return resolve();\n },\n __reset() {\n cached = null;\n disabled = false;\n resolved = false;\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/rate-limit-core",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ttt-productions/ttt-packages.git",