@lunora/ratelimit 1.0.0-alpha.5 → 1.0.0-alpha.7

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/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- export { availableAt, evaluate } from './packem_shared/availableAt-DN3Z6h_K.mjs';
2
- export { default as dbRateLimit } from './packem_shared/dbRateLimit-CXO7O3KO.mjs';
3
- export { default as RateLimitError } from './packem_shared/RateLimitError-53bWrDuJ.mjs';
4
- export { rateLimit } from './packem_shared/rateLimit-uQxVMZfh.mjs';
1
+ export { availableAt, evaluate } from './packem_shared/availableAt-DQcuVfSA.mjs';
2
+ export { default as dbRateLimit } from './packem_shared/dbRateLimit-WTU-e0r6.mjs';
3
+ export { default as RateLimitError } from './packem_shared/RateLimitError-CiLy3DsZ.mjs';
4
+ export { rateLimit } from './packem_shared/rateLimit-BBdG9GFo.mjs';
5
5
  export { ratelimitPlugin } from './packem_shared/ratelimitPlugin-D5_-KV1T.mjs';
6
- export { RateLimiter } from './packem_shared/RateLimiter-DQ9Peq-G.mjs';
6
+ export { RateLimiter } from './packem_shared/RateLimiter-rDCxu_Nx.mjs';
7
7
  export { createDbStore, createMemoryStore, createSqlStore } from './packem_shared/createDbStore-L1kD1g1n.mjs';
8
8
 
9
9
  const VERSION = "0.0.0";
@@ -1,4 +1,5 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
+ import { STATUS_BY_REASON } from './rateLimit-BBdG9GFo.mjs';
2
3
 
3
4
  const describe = (status) => {
4
5
  if (status.reason === "deny") {
@@ -10,7 +11,8 @@ class RateLimitError extends LunoraError {
10
11
  reason;
11
12
  retryAfter;
12
13
  constructor(status, message) {
13
- super("TOO_MANY_REQUESTS", message ?? describe(status), { name: "RateLimitError" });
14
+ const { code, status: httpStatus } = STATUS_BY_REASON[status.reason ?? "rate"];
15
+ super(code, message ?? describe(status), { name: "RateLimitError", status: httpStatus });
14
16
  this.reason = status.reason;
15
17
  this.retryAfter = status.retryAfter;
16
18
  }
@@ -1,6 +1,6 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { availableAt, evaluate } from './availableAt-DN3Z6h_K.mjs';
3
- import RateLimitError from './RateLimitError-53bWrDuJ.mjs';
2
+ import { availableAt, evaluate } from './availableAt-DQcuVfSA.mjs';
3
+ import RateLimitError from './RateLimitError-CiLy3DsZ.mjs';
4
4
  import { createMemoryStore } from './createDbStore-L1kD1g1n.mjs';
5
5
 
6
6
  const storageKeyFor = (name, key) => key === void 0 ? encodeURIComponent(name) : `${encodeURIComponent(name)}:${encodeURIComponent(key)}`;
@@ -16,6 +16,10 @@ const shardKeysFor = (name, key, shards) => {
16
16
  const base = storageKeyFor(name, key);
17
17
  return shards > 1 ? Array.from({ length: shards }, (_, shard) => `${base}#${String(shard)}`) : [base];
18
18
  };
19
+ const routeStorageKey = (name, key, shards) => {
20
+ const base = storageKeyFor(name, key);
21
+ return shards > 1 ? `${base}#${String(hashToShard(base, shards))}` : base;
22
+ };
19
23
  class RateLimiter {
20
24
  config;
21
25
  denyList;
@@ -60,13 +64,8 @@ class RateLimiter {
60
64
  const shards = config.shards ?? 1;
61
65
  const now = this.now();
62
66
  const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
63
- if (shards > 1) {
64
- const base = storageKeyFor(name, normalizedKey);
65
- const storageKey = `${base}#${String(hashToShard(base, shards))}`;
66
- const current2 = availableAt(perShardConfig(config, shards), await this.store.get(storageKey), now);
67
- return { config, ts: current2.ts, value: current2.value };
68
- }
69
- const current = availableAt(config, await this.store.get(storageKeyFor(name, normalizedKey)), now);
67
+ const storageKey = routeStorageKey(name, normalizedKey, shards);
68
+ const current = availableAt(perShardConfig(config, shards), await this.store.get(storageKey), now);
70
69
  return { config, ts: current.ts, value: current.value };
71
70
  }
72
71
  /** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
@@ -101,8 +100,7 @@ class RateLimiter {
101
100
  throw new LunoraError("INTERNAL", `rate limit "${name}": count must be a positive integer`);
102
101
  }
103
102
  const shards = config.shards ?? 1;
104
- const base = storageKeyFor(name, normalizedKey);
105
- const storageKey = shards > 1 ? `${base}#${String(hashToShard(base, shards))}` : base;
103
+ const storageKey = routeStorageKey(name, normalizedKey, shards);
106
104
  const prior = await this.store.get(storageKey);
107
105
  const { status, value } = evaluate(perShardConfig(config, shards), prior, {
108
106
  consume,
@@ -1,12 +1,42 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
2
 
3
3
  const capacityOf = (config) => config.capacity ?? config.rate;
4
- const tokenBucket = (config, prior, options) => {
4
+ const projectTokenBucket = (config, prior, now) => {
5
5
  const capacity = capacityOf(config);
6
6
  const ratePerMs = config.rate / config.period;
7
- const base = prior ?? { ts: options.now, value: capacity };
8
- const elapsed = Math.max(0, options.now - base.ts);
9
- const available = Math.min(capacity, base.value + elapsed * ratePerMs);
7
+ const base = prior ?? { ts: now, value: capacity };
8
+ const elapsed = Math.max(0, now - base.ts);
9
+ return { available: Math.min(capacity, base.value + elapsed * ratePerMs), capacity, ratePerMs };
10
+ };
11
+ const projectFixedWindow = (config, prior, now) => {
12
+ const start = config.start ?? 0;
13
+ const windowStart = start + Math.floor((now - start) / config.period) * config.period;
14
+ if (!prior || prior.ts < windowStart) {
15
+ let carry = 0;
16
+ if (prior && (prior.value < 0 || config.capacity !== void 0)) {
17
+ carry = prior.value;
18
+ }
19
+ return { ts: windowStart, value: Math.min(capacityOf(config), carry + config.rate) };
20
+ }
21
+ return { ts: prior.ts, value: prior.value };
22
+ };
23
+ const projectSlidingWindow = (config, prior, now) => {
24
+ const start = config.start ?? 0;
25
+ const windowStart = start + Math.floor((now - start) / config.period) * config.period;
26
+ const elapsed = now - windowStart;
27
+ const weight = (config.period - elapsed) / config.period;
28
+ let previousCount = 0;
29
+ let currentCount = 0;
30
+ if (prior?.ts === windowStart) {
31
+ previousCount = prior.prev ?? 0;
32
+ currentCount = prior.value;
33
+ } else if (prior?.ts === windowStart - config.period) {
34
+ previousCount = prior.value;
35
+ }
36
+ return { currentCount, elapsed, previousCount, weight, windowStart };
37
+ };
38
+ const tokenBucket = (config, prior, options) => {
39
+ const { available, capacity, ratePerMs } = projectTokenBucket(config, prior, options.now);
10
40
  if (available >= options.count) {
11
41
  const value = { ts: options.now, value: available - options.count };
12
42
  return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
@@ -16,19 +46,14 @@ const tokenBucket = (config, prior, options) => {
16
46
  if (options.consume && options.reserve && options.count <= capacity) {
17
47
  return { status: { ok: true, retryAfter }, value: { ts: options.now, value: available - options.count } };
18
48
  }
49
+ if (options.count > capacity) {
50
+ throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
51
+ }
19
52
  return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
20
53
  };
21
54
  const fixedWindow = (config, prior, options) => {
22
55
  const capacity = capacityOf(config);
23
- const start = config.start ?? 0;
24
- const windowStart = start + Math.floor((options.now - start) / config.period) * config.period;
25
- let base;
26
- if (!prior || prior.ts < windowStart) {
27
- const carry = prior && config.capacity !== void 0 ? Math.max(0, prior.value) : 0;
28
- base = { ts: windowStart, value: Math.min(capacity, carry + config.rate) };
29
- } else {
30
- base = prior;
31
- }
56
+ const base = projectFixedWindow(config, prior, options.now);
32
57
  if (base.value >= options.count) {
33
58
  const value = { ts: base.ts, value: base.value - options.count };
34
59
  return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
@@ -45,18 +70,7 @@ const fixedWindow = (config, prior, options) => {
45
70
  const slidingWindow = (config, prior, options) => {
46
71
  const limit = config.rate;
47
72
  const { period } = config;
48
- const start = config.start ?? 0;
49
- const windowStart = start + Math.floor((options.now - start) / period) * period;
50
- const elapsed = options.now - windowStart;
51
- const weight = (period - elapsed) / period;
52
- let previousCount = 0;
53
- let currentCount = 0;
54
- if (prior?.ts === windowStart) {
55
- previousCount = prior.prev ?? 0;
56
- currentCount = prior.value;
57
- } else if (prior?.ts === windowStart - period) {
58
- previousCount = prior.value;
59
- }
73
+ const { currentCount, elapsed, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, options.now);
60
74
  const estimated = previousCount * weight + currentCount;
61
75
  const admit = estimated + options.count <= limit;
62
76
  const retryAfter = () => {
@@ -79,32 +93,13 @@ const slidingWindow = (config, prior, options) => {
79
93
  };
80
94
  const availableAt = (config, prior, now) => {
81
95
  if (config.kind === "token bucket") {
82
- const capacity = capacityOf(config);
83
- const ratePerMs = config.rate / config.period;
84
- const base = prior ?? { ts: now, value: capacity };
85
- const elapsed = Math.max(0, now - base.ts);
86
- return { ts: now, value: Math.min(capacity, base.value + elapsed * ratePerMs) };
96
+ return { ts: now, value: projectTokenBucket(config, prior, now).available };
87
97
  }
88
- const start = config.start ?? 0;
89
- const windowStart = start + Math.floor((now - start) / config.period) * config.period;
90
98
  if (config.kind === "sliding window") {
91
- const elapsed = now - windowStart;
92
- const weight = (config.period - elapsed) / config.period;
93
- let previousCount = 0;
94
- let currentCount = 0;
95
- if (prior?.ts === windowStart) {
96
- previousCount = prior.prev ?? 0;
97
- currentCount = prior.value;
98
- } else if (prior?.ts === windowStart - config.period) {
99
- previousCount = prior.value;
100
- }
99
+ const { currentCount, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, now);
101
100
  return { ts: windowStart, value: Math.max(0, config.rate - (previousCount * weight + currentCount)) };
102
101
  }
103
- if (!prior || prior.ts < windowStart) {
104
- const carry = prior && config.capacity !== void 0 ? Math.max(0, prior.value) : 0;
105
- return { ts: windowStart, value: Math.min(capacityOf(config), carry + config.rate) };
106
- }
107
- return { ts: prior.ts, value: prior.value };
102
+ return projectFixedWindow(config, prior, now);
108
103
  };
109
104
  const evaluate = (config, prior, options) => {
110
105
  if (config.kind === "token bucket") {
@@ -1,5 +1,5 @@
1
- import { rateLimit } from './rateLimit-uQxVMZfh.mjs';
2
- import { RateLimiter } from './RateLimiter-DQ9Peq-G.mjs';
1
+ import { rateLimit } from './rateLimit-BBdG9GFo.mjs';
2
+ import { RateLimiter } from './RateLimiter-rDCxu_Nx.mjs';
3
3
  import { createDbStore as createDatabaseStore } from './createDbStore-L1kD1g1n.mjs';
4
4
 
5
5
  const databaseRateLimit = (config, name, options = {}) => rateLimit((context) => new RateLimiter({ config, store: createDatabaseStore({ db: context.db, ...options.store }) }), name, options);
@@ -1,3 +1,5 @@
1
+ import { isLunoraError, isInternalCode, LunoraError } from '@lunora/errors';
2
+
1
3
  const STATUS_BY_REASON = {
2
4
  deny: { code: "FORBIDDEN", status: 403 },
3
5
  rate: { code: "TOO_MANY_REQUESTS", status: 429 }
@@ -14,29 +16,25 @@ const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
14
16
  const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
15
17
  status = await resolved.limit(name, { count: options.count, key: options.key?.(ctx) });
16
18
  } catch (error) {
19
+ if (isLunoraError(error) && isInternalCode(error.code)) {
20
+ throw error;
21
+ }
17
22
  console.error(`@lunora/ratelimit: rateLimit("${name}") threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
18
23
  if (options.failOpen) {
19
24
  return next();
20
25
  }
21
- throw Object.assign(new Error(`rate limiter unavailable for "${name}"`), {
22
- cause: error,
23
- code: "SERVICE_UNAVAILABLE",
24
- name: "LunoraError",
25
- status: 503
26
- });
26
+ throw new LunoraError("SERVICE_UNAVAILABLE", `rate limiter unavailable for "${name}"`, { cause: error, status: 503 });
27
27
  }
28
28
  if (!status.ok) {
29
29
  const reason = status.reason ?? "rate";
30
30
  const mapped = STATUS_BY_REASON[reason];
31
31
  const retryAfter = Number.isFinite(status.retryAfter) ? Math.ceil(status.retryAfter) : void 0;
32
- throw Object.assign(new Error(options.message ?? defaultMessage(name, reason, retryAfter)), {
33
- code: mapped.code,
34
- name: "LunoraError",
35
- retryAfter,
36
- status: mapped.status
32
+ throw new LunoraError(mapped.code, options.message ?? defaultMessage(name, reason, retryAfter), {
33
+ status: mapped.status,
34
+ data: retryAfter === void 0 ? void 0 : { retryAfter }
37
35
  });
38
36
  }
39
37
  return next();
40
38
  };
41
39
 
42
- export { rateLimit };
40
+ export { STATUS_BY_REASON, rateLimit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/ratelimit",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.0-alpha.7",
4
4
  "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.2"
49
+ "@lunora/errors": "1.0.0-alpha.4"
50
50
  },
51
51
  "engines": {
52
52
  "node": "^22.15.0 || >=24.11.0"