@lunora/ratelimit 1.0.0-alpha.6 → 1.0.0-alpha.8
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.d.mts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +5 -5
- package/dist/packem_shared/{RateLimitError-53bWrDuJ.mjs → RateLimitError-CiLy3DsZ.mjs} +3 -1
- package/dist/packem_shared/{RateLimiter-DQ9Peq-G.mjs → RateLimiter-rDCxu_Nx.mjs} +9 -11
- package/dist/packem_shared/{availableAt-DN3Z6h_K.mjs → availableAt-DQcuVfSA.mjs} +42 -47
- package/dist/packem_shared/{dbRateLimit-ChciceZm.mjs → dbRateLimit-WTU-e0r6.mjs} +2 -2
- package/dist/packem_shared/{rateLimit-CTJq6iZx.mjs → rateLimit-BBdG9GFo.mjs} +5 -2
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -53,7 +53,6 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
53
53
|
denyList?: Iterable<string>;
|
|
54
54
|
normalize?: (key: string) => string;
|
|
55
55
|
now?: () => number;
|
|
56
|
-
random?: () => number;
|
|
57
56
|
store?: RateLimitStore;
|
|
58
57
|
}
|
|
59
58
|
declare class RateLimiter<Names extends string = string> {
|
package/dist/index.d.ts
CHANGED
|
@@ -53,7 +53,6 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
53
53
|
denyList?: Iterable<string>;
|
|
54
54
|
normalize?: (key: string) => string;
|
|
55
55
|
now?: () => number;
|
|
56
|
-
random?: () => number;
|
|
57
56
|
store?: RateLimitStore;
|
|
58
57
|
}
|
|
59
58
|
declare class RateLimiter<Names extends string = string> {
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export { availableAt, evaluate } from './packem_shared/availableAt-
|
|
2
|
-
export { default as dbRateLimit } from './packem_shared/dbRateLimit-
|
|
3
|
-
export { default as RateLimitError } from './packem_shared/RateLimitError-
|
|
4
|
-
export { rateLimit } from './packem_shared/rateLimit-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
3
|
-
import RateLimitError from './RateLimitError-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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:
|
|
8
|
-
const elapsed = Math.max(0,
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
2
|
-
import { RateLimiter } from './RateLimiter-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
1
|
+
import { isLunoraError, isInternalCode, LunoraError } from '@lunora/errors';
|
|
2
2
|
|
|
3
3
|
const STATUS_BY_REASON = {
|
|
4
4
|
deny: { code: "FORBIDDEN", status: 403 },
|
|
@@ -16,6 +16,9 @@ const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
|
|
|
16
16
|
const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
|
|
17
17
|
status = await resolved.limit(name, { count: options.count, key: options.key?.(ctx) });
|
|
18
18
|
} catch (error) {
|
|
19
|
+
if (isLunoraError(error) && isInternalCode(error.code)) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
19
22
|
console.error(`@lunora/ratelimit: rateLimit("${name}") threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
|
|
20
23
|
if (options.failOpen) {
|
|
21
24
|
return next();
|
|
@@ -34,4 +37,4 @@ const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
|
|
|
34
37
|
return next();
|
|
35
38
|
};
|
|
36
39
|
|
|
37
|
-
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.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
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.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.5"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": "^22.15.0 || >=24.11.0"
|