@voltro/plugin-ratelimit 0.85.0 → 0.87.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,72 @@
1
+ import { parseWindow as e } from "./window.js";
2
+ //#region src/inspect.ts
3
+ var t = (e) => typeof e == "function" ? "dynamic" : e, n = (e) => typeof e == "function" ? "dynamic" : e, r = (r, i) => ({
4
+ id: i === null ? "default" : `r${i}`,
5
+ match: "match" in r ? r.match instanceof RegExp ? String(r.match) : Array.isArray(r.match) ? r.match.join(", ") : r.match ?? "*" : "*",
6
+ kinds: r.kind === void 0 ? [] : typeof r.kind == "string" ? [r.kind] : r.kind,
7
+ limit: t(r.limit),
8
+ windowMs: typeof r.window == "function" ? "dynamic" : e(r.window),
9
+ algorithm: r.algorithm ?? "sliding-window",
10
+ burst: r.burst === void 0 ? t(r.limit) : t(r.burst),
11
+ by: Array.isArray(r.by) ? r.by.map(n) : [n(r.by ?? "subject")],
12
+ scope: r.scope ?? "tag",
13
+ tenantOverrides: Object.keys(r.tenants ?? {}).length
14
+ }), i = () => ({
15
+ allowed: 0,
16
+ rejected: 0,
17
+ bypassed: 0,
18
+ unbound: 0,
19
+ storeErrors: 0,
20
+ lastRejectedAt: null,
21
+ lastStoreErrorAt: null
22
+ }), a = (t, n) => {
23
+ let a = Date.now(), o = {
24
+ rpc: i(),
25
+ http: i()
26
+ }, s = n.bind ? "waiting" : "not-required";
27
+ return {
28
+ binding: (e) => {
29
+ s = e;
30
+ },
31
+ bypass: () => {
32
+ o.rpc.bypassed++;
33
+ },
34
+ record: (e, t) => {
35
+ let n = o[e];
36
+ t.failOpenReason === "unbound" ? n.unbound++ : t.failOpenReason === "store-error" ? (n.storeErrors++, n.lastStoreErrorAt = Date.now()) : t.allowed ? n.allowed++ : (n.rejected++, n.lastRejectedAt = Date.now());
37
+ },
38
+ snapshot: () => {
39
+ let i = null;
40
+ try {
41
+ let e = n.size();
42
+ Number.isFinite(e) && e >= 0 && (i = e);
43
+ } catch {}
44
+ let c = t.store === "redis" && t.redis?.hotStore !== void 0 ? "custom" : typeof t.store == "string" ? t.store : t.store === void 0 ? "memory" : "custom";
45
+ return {
46
+ capturedAt: Date.now(),
47
+ startedAt: a,
48
+ rpcGate: t.enabled === !1 ? "disabled" : typeof t.enabled == "function" ? "dynamic" : "enabled",
49
+ dynamicResolver: t.resolve !== void 0,
50
+ store: {
51
+ kind: c,
52
+ scope: c === "memory" ? "process" : c === "custom" ? "unknown" : "shared-store",
53
+ sqlBinding: s,
54
+ trackedBuckets: i
55
+ },
56
+ rules: (t.rules ?? []).map((e, t) => r(e, t)),
57
+ defaultRule: t.default ? r(t.default, null) : null,
58
+ http: t.http ? {
59
+ limit: t.http.limit,
60
+ windowMs: e(t.http.window),
61
+ algorithm: t.http.algorithm ?? "fixed-window"
62
+ } : null,
63
+ counters: {
64
+ rpc: { ...o.rpc },
65
+ http: { ...o.http }
66
+ }
67
+ };
68
+ }
69
+ };
70
+ };
71
+ //#endregion
72
+ export { a as makeRateLimitInspect };
@@ -1,28 +1,13 @@
1
1
  import { ColumnBuilder } from '@voltro/database';
2
+ import { ConnectionRegistryShape } from '@voltro/kv/connection';
2
3
  import { DataStore } from '@voltro/database';
3
4
  import { Effect } from 'effect';
4
5
  import { FieldDefinitions } from '@voltro/database';
6
+ import { HotRateLimitState } from '@voltro/kv/hot';
5
7
  import { Table } from '@voltro/database';
6
8
 
7
9
  declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
10
 
9
- /** Opaque per-key state. The store persists it verbatim; only this
10
- * module reads its shape. */
11
- declare type BucketState = {
12
- readonly kind: 'fixed';
13
- readonly windowStart: number;
14
- readonly count: number;
15
- } | {
16
- readonly kind: 'sliding';
17
- readonly currStart: number;
18
- readonly currCount: number;
19
- readonly prevCount: number;
20
- } | {
21
- readonly kind: 'token';
22
- readonly tokens: number;
23
- readonly lastRefill: number;
24
- };
25
-
26
11
  export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
27
12
 
28
13
  export declare interface PostgresStoreOptions {
@@ -55,7 +40,7 @@ declare interface RateLimitStore {
55
40
  readonly size: () => number;
56
41
  /** Optional one-shot setup at plugin activate (open pools, create
57
42
  * tables). Receives the process env. */
58
- readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
43
+ readonly activate?: (env: NodeJS.ProcessEnv, connectionRegistry?: ConnectionRegistryShape) => Promise<void>;
59
44
  /** Optional teardown at plugin deactivate (close pools). */
60
45
  readonly deactivate?: () => Promise<void>;
61
46
  /** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
@@ -70,7 +55,7 @@ declare interface RateLimitStore {
70
55
  export declare const rateLimitTable: Table<"_voltro_ratelimit", FieldDefinitions<{
71
56
  readonly id: ColumnBuilder<number, "id", boolean>;
72
57
  readonly key: ColumnBuilder<string, "text", boolean>;
73
- readonly state: ColumnBuilder<BucketState | null, "json", boolean>;
58
+ readonly state: ColumnBuilder< HotRateLimitState | null, "json", boolean>;
74
59
  readonly touchedAt: ColumnBuilder<Date, "timestamp", true>;
75
60
  readonly touched: ColumnBuilder<unknown, "text", boolean>;
76
61
  }>, false, never>;
@@ -1,2 +1,48 @@
1
- import { n as e, r as t, t as n } from "./postgresStore-D_oHSGXq.js";
2
- export { n as RATELIMIT_TABLE, e as postgresStore, t as rateLimitTable };
1
+ import { consumeBucket as e } from "./window.js";
2
+ import { Effect as t } from "effect";
3
+ import { dropped as n, eq as r, id as i, json as a, lt as o, queryFor as s, table as c, text as l, timestamp as u } from "@voltro/database";
4
+ //#region src/postgresStore.ts
5
+ var d = "_voltro_ratelimit", f = c(d, {
6
+ id: i({ scheme: "numeric" }),
7
+ key: l().maxLength(512),
8
+ state: a().nullable(),
9
+ touchedAt: u().default("now"),
10
+ touched: n()
11
+ }).unique("rateLimitKey", ["key"]).nonReactive(), p = (n = {}) => {
12
+ let i = n.staleAfterMs ?? 36e5, a = null, c = (e, t, n) => ({
13
+ failOpenReason: n,
14
+ allowed: !0,
15
+ remaining: e.limit,
16
+ resetAtMs: t + e.windowMs,
17
+ retryAfterMs: 0
18
+ }), l = async (t, n, i, a) => {
19
+ let o = new Date(a);
20
+ return await t.insertIgnore(d, {
21
+ key: n,
22
+ state: null,
23
+ touchedAt: o
24
+ }, { conflictColumns: ["key"] }), t.transactional(async (t) => {
25
+ let c = (await t.query(s(f).where(r("key", n)).forUpdate().descriptor))[0]?.state ?? void 0, { state: l, decision: u } = e(c ?? void 0, i, a);
26
+ return await t.updateMany(d, {
27
+ state: l,
28
+ touchedAt: o
29
+ }, { where: r("key", n) }), u;
30
+ });
31
+ };
32
+ return {
33
+ consume: (e, n, r) => {
34
+ let i = a;
35
+ return i ? t.tryPromise(() => l(i, e, n, r)).pipe(t.catch(() => t.succeed(c(n, r, "store-error")))) : t.succeed(c(n, r, "unbound"));
36
+ },
37
+ sweep: (e) => {
38
+ let t = a;
39
+ t && t.deleteMany(d, { where: o("touchedAt", new Date(e - i)) }).catch(() => {});
40
+ },
41
+ size: () => -1,
42
+ bind: async (e) => {
43
+ a = e;
44
+ }
45
+ };
46
+ };
47
+ //#endregion
48
+ export { d as RATELIMIT_TABLE, p as postgresStore, f as rateLimitTable };
@@ -1,6 +1,8 @@
1
+ import { ConnectionRef } from '@voltro/kv/connection';
2
+ import { ConnectionRegistryShape } from '@voltro/kv/connection';
1
3
  import { DataStore } from '@voltro/database';
2
4
  import { Effect } from 'effect';
3
- import { RespClient } from '@voltro/kv/connection';
5
+ import { HotStoreShape } from '@voltro/kv/hot';
4
6
 
5
7
  declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
6
8
 
@@ -27,7 +29,7 @@ declare interface RateLimitStore {
27
29
  readonly size: () => number;
28
30
  /** Optional one-shot setup at plugin activate (open pools, create
29
31
  * tables). Receives the process env. */
30
- readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
32
+ readonly activate?: (env: NodeJS.ProcessEnv, connectionRegistry?: ConnectionRegistryShape) => Promise<void>;
31
33
  /** Optional teardown at plugin deactivate (close pools). */
32
34
  readonly deactivate?: () => Promise<void>;
33
35
  /** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
@@ -37,32 +39,15 @@ declare interface RateLimitStore {
37
39
  readonly bind?: (store: DataStore) => Promise<void>;
38
40
  }
39
41
 
42
+ /** Redis limiter decisions go only through HotStore. The app owns every socket. */
40
43
  export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
41
44
 
42
45
  export declare interface RedisStoreOptions {
43
- /** Connection url. `resp` driver: `redis://…` / `rediss://…`. `http`
44
- * driver: the Upstash REST url. Falls back to `CACHE_REDIS_URL` /
45
- * `REDIS_URL` env at activate time. */
46
- readonly url?: string;
47
- /**
48
- * How to talk to the server — mirrors the cache plugin's axis:
49
- * - `'resp'` (default) — ioredis over TCP. Works for Redis, Valkey,
50
- * KeyDB, Dragonfly, and Upstash's TCP endpoint.
51
- * - `'http'` — `@upstash/redis` over REST, for serverless/edge where
52
- * TCP isn't available.
53
- * Resolved from `CACHE_REDIS_DRIVER` env when omitted. `'redis'` as a
54
- * store value is the umbrella for the whole RESP family — the server
55
- * brand is just the url; only the DRIVER is a real choice.
56
- */
57
- readonly driver?: 'resp' | 'http';
58
- /** Auth token — `http` (Upstash REST) driver only. Falls back to
59
- * `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` env. */
60
- readonly token?: string;
61
- /** Bring your own already-connected RESP client (from
62
- * `@voltro/kv/connection`'s `connect`, or the shared registry). Takes
63
- * precedence over `url` / `driver`. */
64
- readonly client?: RespClient;
65
- /** Key namespace. Default `'voltro:rl:'`. */
46
+ /** Registry reference. Omit to use the app's default Redis connection. */
47
+ readonly connection?: ConnectionRef<'redis'>;
48
+ /** Borrow a replacement HotStore port. Its owner retains disposal. */
49
+ readonly hotStore?: HotStoreShape;
50
+ /** HotStore namespace. Default 'voltro:rl'; cannot contain braces. */
66
51
  readonly keyPrefix?: string;
67
52
  }
68
53
 
@@ -1,44 +1,48 @@
1
- import { Effect as e } from "effect";
2
- import { connect as t } from "@voltro/kv/connection";
1
+ import { Effect as e, Layer as t, ManagedRuntime as n } from "effect";
2
+ import { ConnectionRegistry as r } from "@voltro/kv/connection";
3
+ import { HotStore as i, layerRoutedHotRedis as a } from "@voltro/kv/hot";
3
4
  //#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, n) => ({
5
+ var o = (o = {}) => {
6
+ if (typeof o != "object" || !o || Array.isArray(o)) throw Error("Redis rate-limit options must be an object");
7
+ if (Object.keys(o).some((e) => ![
8
+ "connection",
9
+ "hotStore",
10
+ "keyPrefix"
11
+ ].includes(e))) throw Error("Redis rate-limit options accept only connection, hotStore and keyPrefix; configure credentials in the app connection registry");
12
+ if (o.hotStore !== void 0 && (o.connection !== void 0 || o.keyPrefix !== void 0)) throw Error("An injected rate limiter HotStore owns its connection and namespace");
13
+ let s = o.hotStore, c, l, u, d = !1, f = (e, t, n) => ({
6
14
  failOpenReason: n,
7
15
  allowed: !0,
8
16
  remaining: e.limit,
9
17
  resetAtMs: t + e.windowMs,
10
18
  retryAfterMs: 0
11
- }), c, l = (e) => r.url ?? e.RATELIMIT_REDIS_URL ?? e.CACHE_REDIS_URL ?? e.REDIS_URL, u = (n) => a ? Promise.resolve() : (c === void 0 && (c = (async () => {
12
- let i = l(n);
13
- if (!i) throw Error("@voltro/plugin-ratelimit redisStore: no client and no url (set RATELIMIT_REDIS_URL / REDIS_URL or pass { url }).");
14
- 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, u = {
15
- url: i,
16
- driver: s,
17
- ...c === void 0 ? {} : { token: c }
18
- };
19
- a = await e.runPromise(t(u)), o = !0;
20
- })().finally(() => {
21
- c = void 0;
22
- })), c), d = (t, r, o) => {
23
- let c = a;
24
- return c ? e.tryPromise(async () => {
25
- let e = await c.eval(n, 1, `${i}${t}`, r.algorithm, r.limit, r.windowMs, r.burst, o);
26
- return JSON.parse(String(e));
27
- }).pipe(e.catch(() => e.succeed(s(r, o, "store-error")))) : e.succeed(s(r, o, "unbound"));
28
- };
19
+ });
29
20
  return {
30
- consume: (t, n, r) => a ? d(t, n, r) : l(process.env) === void 0 ? e.succeed(s(n, r, "unbound")) : e.tryPromise(() => u(process.env)).pipe(e.matchEffect({
31
- onFailure: () => e.succeed(s(n, r, "store-error")),
32
- onSuccess: () => d(t, n, r)
33
- })),
21
+ consume: (t, n, r) => e.suspend(() => {
22
+ let i = s;
23
+ return i === void 0 || d ? e.succeed(f(n, r, "unbound")) : i.rateLimit(t, n, r).pipe(e.catch(() => e.succeed(f(n, r, "store-error"))));
24
+ }),
34
25
  sweep: () => {},
35
26
  size: () => -1,
36
- activate: (e) => u(e),
37
- deactivate: async () => {
38
- let e = a;
39
- a = null, e && o && await e.close();
40
- }
27
+ activate: (e, u) => d ? Promise.reject(/* @__PURE__ */ Error("Rate limiter HotStore scope is closed")) : s === void 0 ? l === void 0 ? u === void 0 ? Promise.reject(/* @__PURE__ */ Error("Redis rate limiter requires the app connection registry or an injected HotStore port")) : (l = (async () => {
28
+ let e = n.make(a({
29
+ consumer: "ratelimit",
30
+ namespace: o.keyPrefix ?? "voltro:rl",
31
+ ...o.connection === void 0 ? {} : { connection: o.connection }
32
+ }).pipe(t.provide(t.succeed(r, u))));
33
+ c = e;
34
+ try {
35
+ s = await e.runPromise(i);
36
+ } catch {
37
+ throw await e.dispose(), c = void 0, Error("Redis rate limiter HotStore activation failed; check the registry reference and environment declarations");
38
+ }
39
+ })().finally(() => {
40
+ l = void 0;
41
+ }), l) : l : Promise.resolve(),
42
+ deactivate: () => u ??= (async () => {
43
+ d = !0, await l?.catch(() => void 0), s = void 0, await c?.dispose();
44
+ })()
41
45
  };
42
46
  };
43
47
  //#endregion
44
- export { r as redisStore };
48
+ export { o as redisStore };
@@ -0,0 +1,65 @@
1
+ import { parseWindow as e } from "./window.js";
2
+ //#region src/resolve.ts
3
+ var t = (e, t) => typeof e == "function" ? e(t) : e, n = /* @__PURE__ */ new WeakMap(), r = (e) => {
4
+ if (!e.global && !e.sticky) return e;
5
+ let t = n.get(e);
6
+ return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")), n.set(e, t)), t;
7
+ }, i = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : r(e).test(t), a = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, o = (e, t) => {
8
+ let n = t === null ? void 0 : e.tenants?.[t];
9
+ return {
10
+ limit: n?.limit ?? e.limit,
11
+ window: n?.window ?? e.window,
12
+ algorithm: n?.algorithm ?? e.algorithm,
13
+ burst: n?.burst ?? e.burst,
14
+ by: e.by ?? "subject",
15
+ scope: e.scope ?? "tag",
16
+ disabled: n?.disabled ?? !1
17
+ };
18
+ }, s = (e, t, n) => {
19
+ for (let t = 0; t < e.length; t++) {
20
+ let r = e[t];
21
+ if (i(r.match, n.tag) && a(r.kind, n.kind)) return {
22
+ effective: o(r, n.tenantId),
23
+ ruleId: `r${t}`
24
+ };
25
+ }
26
+ if (t && a(t.kind, n.kind)) return {
27
+ effective: o(t, n.tenantId),
28
+ ruleId: "default"
29
+ };
30
+ }, c = (n, r) => {
31
+ let i = t(n.limit, r);
32
+ return {
33
+ limit: i,
34
+ windowMs: e(t(n.window, r)),
35
+ algorithm: n.algorithm ?? "sliding-window",
36
+ burst: n.burst === void 0 ? i : t(n.burst, r)
37
+ };
38
+ }, l = (t) => {
39
+ let n = t.limit;
40
+ return {
41
+ limit: n,
42
+ windowMs: e(t.window),
43
+ algorithm: t.algorithm ?? "sliding-window",
44
+ burst: t.burst ?? n
45
+ };
46
+ }, u = (e) => ({
47
+ limit: e.limit,
48
+ window: e.windowMs,
49
+ algorithm: e.algorithm,
50
+ burst: e.burst
51
+ }), d = (e, t) => {
52
+ if (typeof e == "function") return e(t);
53
+ switch (e) {
54
+ case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
55
+ case "tenant": return `t:${t.tenantId ?? "∅"}`;
56
+ case "apiKey": return t.subject.type === "apiKey" ? `k:${t.subject.id}` : `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
57
+ case "global": return "g";
58
+ case "ip": return `i:${t.remoteAddr ?? "unknown"}`;
59
+ }
60
+ }, f = (e, t) => {
61
+ let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
62
+ return `${i}|${Array.isArray(a) ? a.map((e) => d(e, t)).join("|") : d(a, t)}`;
63
+ };
64
+ //#endregion
65
+ export { f as buildKey, l as limitFromOverride, s as matchRule, c as resolveLimit, u as toOverride };
package/dist/store.js ADDED
@@ -0,0 +1,29 @@
1
+ import { consumeBucket as e } from "./window.js";
2
+ import { Effect as t } from "effect";
3
+ //#region src/store.ts
4
+ var n = (n = {}) => {
5
+ let r = n.maxKeys ?? 1e5, i = n.staleAfterMs ?? 36e5, a = /* @__PURE__ */ new Map(), o = (e) => {
6
+ if (a.size <= r) return;
7
+ let t = a.size - r, n = 0;
8
+ for (let r of a.keys()) {
9
+ if (n >= t) break;
10
+ r !== e && (a.delete(r), n += 1);
11
+ }
12
+ };
13
+ return {
14
+ consume: (n, r, i) => t.sync(() => {
15
+ let t = a.get(n), { state: s, decision: c } = e(t?.state, r, i);
16
+ return a.set(n, {
17
+ state: s,
18
+ touched: i
19
+ }), o(n), c;
20
+ }),
21
+ sweep: (e) => {
22
+ let t = e - i;
23
+ for (let [e, n] of a) n.touched < t && a.delete(e);
24
+ },
25
+ size: () => a.size
26
+ };
27
+ };
28
+ //#endregion
29
+ export { n as memoryStore };
package/dist/window.js ADDED
@@ -0,0 +1,19 @@
1
+ import { consumeHotRateLimit as e } from "@voltro/kv/hot/contract";
2
+ //#region src/window.ts
3
+ var t = {
4
+ ms: 1,
5
+ s: 1e3,
6
+ m: 6e4,
7
+ h: 36e5,
8
+ d: 864e5
9
+ }, n = (e) => {
10
+ if (typeof e == "number") {
11
+ if (!Number.isFinite(e) || e <= 0) throw Error(`@voltro/plugin-ratelimit: window must be a positive number, got ${e}`);
12
+ return e;
13
+ }
14
+ let n = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(e.trim());
15
+ if (!n) throw Error(`@voltro/plugin-ratelimit: invalid window "${e}" — use e.g. "1m", "10s", "500ms", "2h", "1d"`);
16
+ return Number(n[1]) * t[n[2]];
17
+ }, r = e;
18
+ //#endregion
19
+ export { r as consumeBucket, n as parseWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-ratelimit",
3
- "version": "0.85.0",
3
+ "version": "0.87.0",
4
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
5
  "keywords": [
6
6
  "voltro",
@@ -48,10 +48,12 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/database": "0.85.0",
52
- "@voltro/kv": "0.85.0",
53
- "@voltro/logger": "0.85.0",
54
- "@voltro/protocol": "0.85.0"
51
+ "@voltro/shared": "0.87.0",
52
+ "@voltro/database": "0.87.0",
53
+ "@voltro/kv": "0.87.0",
54
+ "@voltro/logger": "0.87.0",
55
+ "@voltro/protocol": "0.87.0",
56
+ "@voltro/runtime": "0.87.0"
55
57
  },
56
58
  "peerDependencies": {
57
59
  "effect": "4.0.1"
@@ -1,160 +0,0 @@
1
- import { Effect as e } from "effect";
2
- import { dropped as t, eq as n, id as r, json as i, lt as a, queryFor as o, table as s, text as c, timestamp as l } from "@voltro/database";
3
- //#region src/window.ts
4
- var u = {
5
- ms: 1,
6
- s: 1e3,
7
- m: 6e4,
8
- h: 36e5,
9
- d: 864e5
10
- }, d = (e) => {
11
- if (typeof e == "number") {
12
- if (!Number.isFinite(e) || e <= 0) throw Error(`@voltro/plugin-ratelimit: window must be a positive number, got ${e}`);
13
- return e;
14
- }
15
- let t = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(e.trim());
16
- if (!t) throw Error(`@voltro/plugin-ratelimit: invalid window "${e}" — use e.g. "1m", "10s", "500ms", "2h", "1d"`);
17
- return Number(t[1]) * u[t[2]];
18
- }, f = (e, t, n) => {
19
- switch (t.algorithm) {
20
- case "fixed-window": return p(e, t, n);
21
- case "sliding-window": return m(e, t, n);
22
- case "token-bucket": return h(e, t, n);
23
- }
24
- }, p = (e, t, n) => {
25
- let r = e?.kind === "fixed" ? e.windowStart : n, i = e?.kind === "fixed" ? e.count : 0;
26
- n - r >= t.windowMs && (r = n, i = 0);
27
- let a = r + t.windowMs;
28
- return i >= t.limit ? {
29
- state: {
30
- kind: "fixed",
31
- windowStart: r,
32
- count: i
33
- },
34
- decision: {
35
- allowed: !1,
36
- remaining: 0,
37
- resetAtMs: a,
38
- retryAfterMs: a - n
39
- }
40
- } : (i += 1, {
41
- state: {
42
- kind: "fixed",
43
- windowStart: r,
44
- count: i
45
- },
46
- decision: {
47
- allowed: !0,
48
- remaining: t.limit - i,
49
- resetAtMs: a,
50
- retryAfterMs: 0
51
- }
52
- });
53
- }, m = (e, t, n) => {
54
- 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);
55
- s >= 2 ? (o = 0, a = 0, i = n) : s === 1 && (o = a, a = 0, i += r);
56
- let c = (r - (n - i)) / r, l = o * c + a, u = i + r;
57
- return l >= t.limit ? {
58
- state: {
59
- kind: "sliding",
60
- currStart: i,
61
- currCount: a,
62
- prevCount: o
63
- },
64
- decision: {
65
- allowed: !1,
66
- remaining: 0,
67
- resetAtMs: u,
68
- retryAfterMs: u - n
69
- }
70
- } : (a += 1, {
71
- state: {
72
- kind: "sliding",
73
- currStart: i,
74
- currCount: a,
75
- prevCount: o
76
- },
77
- decision: {
78
- allowed: !0,
79
- remaining: Math.max(0, Math.floor(t.limit - l - 1)),
80
- resetAtMs: u,
81
- retryAfterMs: 0
82
- }
83
- });
84
- }, h = (e, t, n) => {
85
- let r = t.burst, i = t.limit / t.windowMs, a = e?.kind === "token" ? e.tokens : r, o = e?.kind === "token" ? e.lastRefill : n;
86
- if (a = Math.min(r, a + (n - o) * i), a >= 1) {
87
- --a;
88
- let e = n + Math.ceil((r - a) / i);
89
- return {
90
- state: {
91
- kind: "token",
92
- tokens: a,
93
- lastRefill: n
94
- },
95
- decision: {
96
- allowed: !0,
97
- remaining: Math.floor(a),
98
- resetAtMs: e,
99
- retryAfterMs: 0
100
- }
101
- };
102
- }
103
- let s = Math.ceil((1 - a) / i);
104
- return {
105
- state: {
106
- kind: "token",
107
- tokens: a,
108
- lastRefill: n
109
- },
110
- decision: {
111
- allowed: !1,
112
- remaining: 0,
113
- resetAtMs: n + s,
114
- retryAfterMs: s
115
- }
116
- };
117
- }, g = "_voltro_ratelimit", _ = s(g, {
118
- id: r({ scheme: "numeric" }),
119
- key: c().maxLength(512),
120
- state: i().nullable(),
121
- touchedAt: l().default("now"),
122
- touched: t()
123
- }).unique("rateLimitKey", ["key"]).nonReactive(), v = (t = {}) => {
124
- let r = t.staleAfterMs ?? 36e5, i = null, s = (e, t, n) => ({
125
- failOpenReason: n,
126
- allowed: !0,
127
- remaining: e.limit,
128
- resetAtMs: t + e.windowMs,
129
- retryAfterMs: 0
130
- }), c = async (e, t, r, i) => {
131
- let a = new Date(i);
132
- return await e.insertIgnore(g, {
133
- key: t,
134
- state: null,
135
- touchedAt: a
136
- }, { conflictColumns: ["key"] }), e.transactional(async (e) => {
137
- let { state: s, decision: c } = f((await e.query(o(_).where(n("key", t)).forUpdate().descriptor))[0]?.state ?? void 0 ?? void 0, r, i);
138
- return await e.updateMany(g, {
139
- state: s,
140
- touchedAt: a
141
- }, { where: n("key", t) }), c;
142
- });
143
- };
144
- return {
145
- consume: (t, n, r) => {
146
- let a = i;
147
- return a ? e.tryPromise(() => c(a, t, n, r)).pipe(e.catch(() => e.succeed(s(n, r, "store-error")))) : e.succeed(s(n, r, "unbound"));
148
- },
149
- sweep: (e) => {
150
- let t = i;
151
- t && t.deleteMany(g, { where: a("touchedAt", new Date(e - r)) }).catch(() => {});
152
- },
153
- size: () => -1,
154
- bind: async (e) => {
155
- i = e;
156
- }
157
- };
158
- };
159
- //#endregion
160
- export { d as a, f as i, v as n, _ as r, g as t };