@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.
@@ -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
+ }