@voltro/plugin-ratelimit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +827 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +319 -0
- package/dist/index.js +243 -0
- package/dist/postgresStore.d.ts +55 -0
- package/dist/postgresStore.js +50 -0
- package/dist/redisStore.d.ts +76 -0
- package/dist/redisStore.js +39 -0
- package/dist/window-lyQ6LXhj.js +117 -0
- package/package.json +62 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { t as e } from "./window-lyQ6LXhj.js";
|
|
2
|
+
import { Effect as t } from "effect";
|
|
3
|
+
import { SqlClient as n } from "@effect/sql";
|
|
4
|
+
//#region src/postgresStore.ts
|
|
5
|
+
var r = (r = {}) => {
|
|
6
|
+
let i = r.table ?? "_voltro_ratelimit", a = r.staleAfterMs ?? 36e5, o = null, s = (e) => t.runPromise(t.provideService(e, n.SqlClient, o)), c = (e, t) => ({
|
|
7
|
+
allowed: !0,
|
|
8
|
+
remaining: e.limit,
|
|
9
|
+
resetAtMs: t + e.windowMs,
|
|
10
|
+
retryAfterMs: 0
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
consume: (r, a, l) => {
|
|
14
|
+
if (!o) return t.succeed(c(a, l));
|
|
15
|
+
let u = t.gen(function* () {
|
|
16
|
+
let o = yield* n.SqlClient;
|
|
17
|
+
return yield* o.withTransaction(t.gen(function* () {
|
|
18
|
+
yield* o`SELECT pg_advisory_xact_lock(hashtextextended(${r}, 0))`;
|
|
19
|
+
let t = (yield* o`
|
|
20
|
+
SELECT state FROM ${o(i)} WHERE key = ${r} FOR UPDATE`)[0]?.state, { state: n, decision: s } = e(t, a, l), c = JSON.stringify(n);
|
|
21
|
+
return yield* o`
|
|
22
|
+
INSERT INTO ${o(i)} (key, state, touched)
|
|
23
|
+
VALUES (${r}, ${c}::jsonb, ${l})
|
|
24
|
+
ON CONFLICT (key) DO UPDATE SET state = ${c}::jsonb, touched = ${l}`, s;
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
return t.tryPromise(() => s(u)).pipe(t.catchAll(() => t.succeed(c(a, l))));
|
|
28
|
+
},
|
|
29
|
+
sweep: (e) => {
|
|
30
|
+
o && s(t.gen(function* () {
|
|
31
|
+
let t = yield* n.SqlClient;
|
|
32
|
+
yield* t`DELETE FROM ${t(i)} WHERE touched < ${e - a}`;
|
|
33
|
+
})).catch(() => {});
|
|
34
|
+
},
|
|
35
|
+
size: () => -1,
|
|
36
|
+
bindSql: async (e) => {
|
|
37
|
+
await t.runPromise(t.provideService(t.gen(function* () {
|
|
38
|
+
let e = yield* n.SqlClient;
|
|
39
|
+
yield* e`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS ${e(i)} (
|
|
41
|
+
key TEXT PRIMARY KEY,
|
|
42
|
+
state JSONB NOT NULL,
|
|
43
|
+
touched BIGINT NOT NULL
|
|
44
|
+
)`;
|
|
45
|
+
}), n.SqlClient, e)), o = e;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
export { r as postgresStore };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { RespClient } from '@voltro/kv/connection';
|
|
3
|
+
import { SqlClient } from '@effect/sql';
|
|
4
|
+
|
|
5
|
+
declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
6
|
+
|
|
7
|
+
declare interface RateLimitDecision {
|
|
8
|
+
readonly allowed: boolean;
|
|
9
|
+
/** Remaining allowance in the current window (best-effort estimate). */
|
|
10
|
+
readonly remaining: number;
|
|
11
|
+
/** Epoch-ms when the bucket is expected to be fully replenished. */
|
|
12
|
+
readonly resetAtMs: number;
|
|
13
|
+
/** How long the caller should wait before retrying. 0 when allowed. */
|
|
14
|
+
readonly retryAfterMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare interface RateLimitStore {
|
|
18
|
+
/** Atomically consume one unit for `key` against `limit` at `nowMs`. */
|
|
19
|
+
readonly consume: (key: string, limit: ResolvedLimit, nowMs: number) => Effect.Effect<RateLimitDecision>;
|
|
20
|
+
/** Drop entries untouched long enough to be irrelevant. Called on a timer. */
|
|
21
|
+
readonly sweep: (nowMs: number) => void;
|
|
22
|
+
/** Current number of tracked keys (diagnostics / tests). `-1` when the
|
|
23
|
+
* store can't answer synchronously (e.g. postgres). */
|
|
24
|
+
readonly size: () => number;
|
|
25
|
+
/** Optional one-shot setup at plugin activate (open pools, create
|
|
26
|
+
* tables). Receives the process env. */
|
|
27
|
+
readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
|
|
28
|
+
/** Optional teardown at plugin deactivate (close pools). */
|
|
29
|
+
readonly deactivate?: () => Promise<void>;
|
|
30
|
+
/** Optional bind of the framework's already-open `SqlClient` (from the
|
|
31
|
+
* plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
|
|
32
|
+
* runs through THIS client instead of standing up its own pool; creates
|
|
33
|
+
* its bookkeeping table here. Not present on memory / redis stores. */
|
|
34
|
+
readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
|
|
38
|
+
|
|
39
|
+
export declare interface RedisStoreOptions {
|
|
40
|
+
/** Connection url. `resp` driver: `redis://…` / `rediss://…`. `http`
|
|
41
|
+
* driver: the Upstash REST url. Falls back to `CACHE_REDIS_URL` /
|
|
42
|
+
* `REDIS_URL` env at activate time. */
|
|
43
|
+
readonly url?: string;
|
|
44
|
+
/**
|
|
45
|
+
* How to talk to the server — mirrors the cache plugin's axis:
|
|
46
|
+
* - `'resp'` (default) — ioredis over TCP. Works for Redis, Valkey,
|
|
47
|
+
* KeyDB, Dragonfly, and Upstash's TCP endpoint.
|
|
48
|
+
* - `'http'` — `@upstash/redis` over REST, for serverless/edge where
|
|
49
|
+
* TCP isn't available.
|
|
50
|
+
* Resolved from `CACHE_REDIS_DRIVER` env when omitted. `'redis'` as a
|
|
51
|
+
* store value is the umbrella for the whole RESP family — the server
|
|
52
|
+
* brand is just the url; only the DRIVER is a real choice.
|
|
53
|
+
*/
|
|
54
|
+
readonly driver?: 'resp' | 'http';
|
|
55
|
+
/** Auth token — `http` (Upstash REST) driver only. Falls back to
|
|
56
|
+
* `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` env. */
|
|
57
|
+
readonly token?: string;
|
|
58
|
+
/** Bring your own already-connected RESP client (from
|
|
59
|
+
* `@voltro/kv/connection`'s `connect`, or the shared registry). Takes
|
|
60
|
+
* precedence over `url` / `driver`. */
|
|
61
|
+
readonly client?: RespClient;
|
|
62
|
+
/** Key namespace. Default `'voltro:rl:'`. */
|
|
63
|
+
readonly keyPrefix?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The fully-resolved limit the store consumes against. All dynamic /
|
|
67
|
+
* per-tenant resolution has already collapsed into these scalars. */
|
|
68
|
+
declare interface ResolvedLimit {
|
|
69
|
+
readonly limit: number;
|
|
70
|
+
readonly windowMs: number;
|
|
71
|
+
readonly algorithm: Algorithm;
|
|
72
|
+
/** Token-bucket capacity. Equals `limit` unless an explicit burst was set. */
|
|
73
|
+
readonly burst: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Effect as e } from "effect";
|
|
2
|
+
import { connect as t } from "@voltro/kv/connection";
|
|
3
|
+
//#region src/redisStore.ts
|
|
4
|
+
var n = "\nlocal key = KEYS[1]\nlocal algo = ARGV[1]\nlocal limit = tonumber(ARGV[2])\nlocal windowMs = tonumber(ARGV[3])\nlocal burst = tonumber(ARGV[4])\nlocal now = tonumber(ARGV[5])\n\nlocal raw = redis.call('GET', key)\nlocal s = nil\nif raw then s = cjson.decode(raw) end\n\nlocal allowed, remaining, resetAt, retryAfter\nlocal newstate\n\nif algo == 'fixed-window' then\n local windowStart = (s and s.windowStart) or now\n local count = (s and s.count) or 0\n if now - windowStart >= windowMs then windowStart = now; count = 0 end\n resetAt = windowStart + windowMs\n if count >= limit then\n allowed = false; remaining = 0; retryAfter = resetAt - now\n else\n count = count + 1; allowed = true; remaining = limit - count; retryAfter = 0\n end\n newstate = { kind = 'fixed', windowStart = windowStart, count = count }\n\nelseif algo == 'token-bucket' then\n local capacity = burst\n local refillPerMs = limit / windowMs\n local tokens = (s and s.tokens) or capacity\n local lastRefill = (s and s.lastRefill) or now\n tokens = math.min(capacity, tokens + (now - lastRefill) * refillPerMs)\n if tokens >= 1 then\n tokens = tokens - 1\n resetAt = now + math.ceil((capacity - tokens) / refillPerMs)\n allowed = true; remaining = math.floor(tokens); retryAfter = 0\n else\n retryAfter = math.ceil((1 - tokens) / refillPerMs)\n resetAt = now + retryAfter; allowed = false; remaining = 0\n end\n newstate = { kind = 'token', tokens = tokens, lastRefill = now }\n\nelse\n -- sliding-window (weighted counter)\n local w = windowMs\n local currStart = (s and s.currStart) or now\n local currCount = (s and s.currCount) or 0\n local prevCount = (s and s.prevCount) or 0\n local elapsed = math.floor((now - currStart) / w)\n if elapsed >= 2 then\n prevCount = 0; currCount = 0; currStart = now\n elseif elapsed == 1 then\n prevCount = currCount; currCount = 0; currStart = currStart + w\n end\n local intoCurr = now - currStart\n local weight = (w - intoCurr) / w\n local estimate = prevCount * weight + currCount\n resetAt = currStart + w\n if estimate >= limit then\n allowed = false; remaining = 0; retryAfter = resetAt - now\n else\n currCount = currCount + 1\n allowed = true\n remaining = math.max(0, math.floor(limit - estimate - 1))\n retryAfter = 0\n end\n newstate = { kind = 'sliding', currStart = currStart, currCount = currCount, prevCount = prevCount }\nend\n\nlocal ttl = math.max(windowMs * 2, 60000) + 1000\nredis.call('SET', key, cjson.encode(newstate), 'PX', ttl)\nreturn cjson.encode({ allowed = allowed, remaining = remaining, resetAtMs = resetAt, retryAfterMs = retryAfter })\n", r = (r = {}) => {
|
|
5
|
+
let i = r.keyPrefix ?? "voltro:rl:", a = r.client ?? null, o = !1, s = (e, t) => ({
|
|
6
|
+
allowed: !0,
|
|
7
|
+
remaining: e.limit,
|
|
8
|
+
resetAtMs: t + e.windowMs,
|
|
9
|
+
retryAfterMs: 0
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
consume: (t, r, o) => {
|
|
13
|
+
let c = a;
|
|
14
|
+
return c ? e.tryPromise(async () => {
|
|
15
|
+
let e = await c.eval(n, 1, `${i}${t}`, r.algorithm, r.limit, r.windowMs, r.burst, o);
|
|
16
|
+
return JSON.parse(String(e));
|
|
17
|
+
}).pipe(e.catchAll(() => e.succeed(s(r, o)))) : e.succeed(s(r, o));
|
|
18
|
+
},
|
|
19
|
+
sweep: () => {},
|
|
20
|
+
size: () => -1,
|
|
21
|
+
activate: async (n) => {
|
|
22
|
+
if (a) return;
|
|
23
|
+
let i = r.url ?? n.RATELIMIT_REDIS_URL ?? n.CACHE_REDIS_URL ?? n.REDIS_URL;
|
|
24
|
+
if (!i) throw Error("@voltro/plugin-ratelimit redisStore: no client and no url (set RATELIMIT_REDIS_URL / REDIS_URL or pass { url }).");
|
|
25
|
+
let s = r.driver ?? ((n.RATELIMIT_REDIS_DRIVER ?? n.CACHE_REDIS_DRIVER) === "http" ? "http" : "resp"), c = r.token ?? n.RATELIMIT_REDIS_TOKEN ?? n.CACHE_REDIS_TOKEN ?? n.UPSTASH_REDIS_REST_TOKEN, l = {
|
|
26
|
+
url: i,
|
|
27
|
+
driver: s,
|
|
28
|
+
...c === void 0 ? {} : { token: c }
|
|
29
|
+
};
|
|
30
|
+
a = await e.runPromise(t(l)), o = !0;
|
|
31
|
+
},
|
|
32
|
+
deactivate: async () => {
|
|
33
|
+
let e = a;
|
|
34
|
+
a = null, e && o && await e.close();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { r as redisStore };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//#region src/window.ts
|
|
2
|
+
var e = {
|
|
3
|
+
ms: 1,
|
|
4
|
+
s: 1e3,
|
|
5
|
+
m: 6e4,
|
|
6
|
+
h: 36e5,
|
|
7
|
+
d: 864e5
|
|
8
|
+
}, t = (t) => {
|
|
9
|
+
if (typeof t == "number") {
|
|
10
|
+
if (!Number.isFinite(t) || t <= 0) throw Error(`@voltro/plugin-ratelimit: window must be a positive number, got ${t}`);
|
|
11
|
+
return t;
|
|
12
|
+
}
|
|
13
|
+
let n = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(t.trim());
|
|
14
|
+
if (!n) throw Error(`@voltro/plugin-ratelimit: invalid window "${t}" — use e.g. "1m", "10s", "500ms", "2h", "1d"`);
|
|
15
|
+
return Number(n[1]) * e[n[2]];
|
|
16
|
+
}, n = (e, t, n) => {
|
|
17
|
+
switch (t.algorithm) {
|
|
18
|
+
case "fixed-window": return r(e, t, n);
|
|
19
|
+
case "sliding-window": return i(e, t, n);
|
|
20
|
+
case "token-bucket": return a(e, t, n);
|
|
21
|
+
}
|
|
22
|
+
}, r = (e, t, n) => {
|
|
23
|
+
let r = e?.kind === "fixed" ? e.windowStart : n, i = e?.kind === "fixed" ? e.count : 0;
|
|
24
|
+
n - r >= t.windowMs && (r = n, i = 0);
|
|
25
|
+
let a = r + t.windowMs;
|
|
26
|
+
return i >= t.limit ? {
|
|
27
|
+
state: {
|
|
28
|
+
kind: "fixed",
|
|
29
|
+
windowStart: r,
|
|
30
|
+
count: i
|
|
31
|
+
},
|
|
32
|
+
decision: {
|
|
33
|
+
allowed: !1,
|
|
34
|
+
remaining: 0,
|
|
35
|
+
resetAtMs: a,
|
|
36
|
+
retryAfterMs: a - n
|
|
37
|
+
}
|
|
38
|
+
} : (i += 1, {
|
|
39
|
+
state: {
|
|
40
|
+
kind: "fixed",
|
|
41
|
+
windowStart: r,
|
|
42
|
+
count: i
|
|
43
|
+
},
|
|
44
|
+
decision: {
|
|
45
|
+
allowed: !0,
|
|
46
|
+
remaining: t.limit - i,
|
|
47
|
+
resetAtMs: a,
|
|
48
|
+
retryAfterMs: 0
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}, i = (e, t, n) => {
|
|
52
|
+
let r = t.windowMs, i = e?.kind === "sliding" ? e.currStart : n, a = e?.kind === "sliding" ? e.currCount : 0, o = e?.kind === "sliding" ? e.prevCount : 0, s = Math.floor((n - i) / r);
|
|
53
|
+
s >= 2 ? (o = 0, a = 0, i = n) : s === 1 && (o = a, a = 0, i += r);
|
|
54
|
+
let c = (r - (n - i)) / r, l = o * c + a, u = i + r;
|
|
55
|
+
return l >= t.limit ? {
|
|
56
|
+
state: {
|
|
57
|
+
kind: "sliding",
|
|
58
|
+
currStart: i,
|
|
59
|
+
currCount: a,
|
|
60
|
+
prevCount: o
|
|
61
|
+
},
|
|
62
|
+
decision: {
|
|
63
|
+
allowed: !1,
|
|
64
|
+
remaining: 0,
|
|
65
|
+
resetAtMs: u,
|
|
66
|
+
retryAfterMs: u - n
|
|
67
|
+
}
|
|
68
|
+
} : (a += 1, {
|
|
69
|
+
state: {
|
|
70
|
+
kind: "sliding",
|
|
71
|
+
currStart: i,
|
|
72
|
+
currCount: a,
|
|
73
|
+
prevCount: o
|
|
74
|
+
},
|
|
75
|
+
decision: {
|
|
76
|
+
allowed: !0,
|
|
77
|
+
remaining: Math.max(0, Math.floor(t.limit - l - 1)),
|
|
78
|
+
resetAtMs: u,
|
|
79
|
+
retryAfterMs: 0
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}, a = (e, t, n) => {
|
|
83
|
+
let r = t.burst, i = t.limit / t.windowMs, a = e?.kind === "token" ? e.tokens : r, o = e?.kind === "token" ? e.lastRefill : n;
|
|
84
|
+
if (a = Math.min(r, a + (n - o) * i), a >= 1) {
|
|
85
|
+
--a;
|
|
86
|
+
let e = n + Math.ceil((r - a) / i);
|
|
87
|
+
return {
|
|
88
|
+
state: {
|
|
89
|
+
kind: "token",
|
|
90
|
+
tokens: a,
|
|
91
|
+
lastRefill: n
|
|
92
|
+
},
|
|
93
|
+
decision: {
|
|
94
|
+
allowed: !0,
|
|
95
|
+
remaining: Math.floor(a),
|
|
96
|
+
resetAtMs: e,
|
|
97
|
+
retryAfterMs: 0
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
let s = Math.ceil((1 - a) / i);
|
|
102
|
+
return {
|
|
103
|
+
state: {
|
|
104
|
+
kind: "token",
|
|
105
|
+
tokens: a,
|
|
106
|
+
lastRefill: n
|
|
107
|
+
},
|
|
108
|
+
decision: {
|
|
109
|
+
allowed: !1,
|
|
110
|
+
remaining: 0,
|
|
111
|
+
resetAtMs: n + s,
|
|
112
|
+
retryAfterMs: s
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
export { t as n, n as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voltro/plugin-ratelimit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rate-limiting plugin — per-endpoint, per-subject and per-tenant request limits via the RPC interceptors. Highly configurable rules (fixed-window / sliding-window / token-bucket), composite keying, static per-tenant overrides and a dynamic resolve() hook. Stores: memory (default, single-node), postgres + redis (multi-node, atomic).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"voltro",
|
|
7
|
+
"typescript",
|
|
8
|
+
"framework"
|
|
9
|
+
],
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"homepage": "https://voltro.dev",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "support@voltro.dev"
|
|
14
|
+
},
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Voltro UG",
|
|
17
|
+
"url": "https://voltro.dev"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./postgres": {
|
|
27
|
+
"types": "./dist/postgresStore.d.ts",
|
|
28
|
+
"import": "./dist/postgresStore.js",
|
|
29
|
+
"default": "./dist/postgresStore.js"
|
|
30
|
+
},
|
|
31
|
+
"./redis": {
|
|
32
|
+
"types": "./dist/redisStore.d.ts",
|
|
33
|
+
"import": "./dist/redisStore.js",
|
|
34
|
+
"default": "./dist/redisStore.js"
|
|
35
|
+
},
|
|
36
|
+
"./errors": {
|
|
37
|
+
"types": "./dist/errors.d.ts",
|
|
38
|
+
"import": "./dist/errors.js",
|
|
39
|
+
"default": "./dist/errors.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"main": "./dist/index.js",
|
|
43
|
+
"module": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=24.0.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@voltro/kv": "0.1.0",
|
|
51
|
+
"@voltro/protocol": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"optionalDependencies": {
|
|
54
|
+
"@effect/sql": "^0.51.1"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"effect": "^3.21.4"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|