@voltro/plugin-ratelimit 0.86.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.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { ColumnBuilder } from '@voltro/database';
2
+ import { ConnectionRef } from '@voltro/kv/connection';
3
+ import { ConnectionRegistryShape } from '@voltro/kv/connection';
2
4
  import { DataStore } from '@voltro/database';
3
5
  import { Effect } from 'effect';
4
6
  import { FieldDefinitions } from '@voltro/database';
5
- import { RespClient } from '@voltro/kv/connection';
7
+ import { HotRateLimitState } from '@voltro/kv/hot';
8
+ import { HotRateLimitState as HotRateLimitState_2 } from '@voltro/kv/hot/contract';
9
+ import { HotStoreShape } from '@voltro/kv/hot';
6
10
  import { RpcKind } from '@voltro/protocol';
7
11
  import { Schema } from 'effect';
8
12
  import { Subject } from '@voltro/protocol';
@@ -19,20 +23,7 @@ declare interface BucketResult {
19
23
 
20
24
  /** Opaque per-key state. The store persists it verbatim; only this
21
25
  * module reads its shape. */
22
- export declare type BucketState = {
23
- readonly kind: 'fixed';
24
- readonly windowStart: number;
25
- readonly count: number;
26
- } | {
27
- readonly kind: 'sliding';
28
- readonly currStart: number;
29
- readonly currCount: number;
30
- readonly prevCount: number;
31
- } | {
32
- readonly kind: 'token';
33
- readonly tokens: number;
34
- readonly lastRefill: number;
35
- };
26
+ export declare type BucketState = HotRateLimitState_2;
36
27
 
37
28
  export declare const consumeBucket: (prev: BucketState | undefined, limit: ResolvedLimit, now: number) => BucketResult;
38
29
 
@@ -180,17 +171,16 @@ export declare interface RateLimitPluginOptions {
180
171
  /**
181
172
  * Counter backend:
182
173
  * - `'memory'` (default) — single-node, zero-config.
183
- * - `'postgres'` — multi-node correct; opens its own pool from the
184
- * same `DB_*`/`PG_*` env the framework reads, creates
185
- * `_voltro_ratelimit`, mutates state under a row lock. Fails OPEN
186
- * on db errors (degrades to no-limit, never to 500s).
187
- * - `'redis'` — multi-node, fastest; connects ioredis from
188
- * `CACHE_REDIS_URL`/`REDIS_URL`, runs the whole decision in one
189
- * atomic Lua script. For a custom client/url use the
190
- * `redisStore({...})` factory from `@voltro/plugin-ratelimit/redis`.
174
+ * - `'postgres'` — counters in the app's declared schema, bound through
175
+ * `DataStore`; atomic transactions, fail-open on unavailable storage.
176
+ * - `'redis'` — atomic HotStore decisions over the app-owned registry.
177
+ * Use `redis.connection` to select a declared Redis connection or
178
+ * `redis.hotStore` to borrow a replacement port.
191
179
  * - a custom `RateLimitStore`.
192
180
  */
193
181
  readonly store?: 'memory' | 'postgres' | 'redis' | RateLimitStore;
182
+ /** Registry reference or replacement HotStore for the Redis counter backend. */
183
+ readonly redis?: RedisStoreOptions;
194
184
  /**
195
185
  * Master gate. `false` disables all limiting; a function lets you gate
196
186
  * per-call (e.g. exempt a tenant, or a maintenance bypass). Returning
@@ -263,7 +253,7 @@ export declare interface RateLimitStore {
263
253
  readonly size: () => number;
264
254
  /** Optional one-shot setup at plugin activate (open pools, create
265
255
  * tables). Receives the process env. */
266
- readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
256
+ readonly activate?: (env: NodeJS.ProcessEnv, connectionRegistry?: ConnectionRegistryShape) => Promise<void>;
267
257
  /** Optional teardown at plugin deactivate (close pools). */
268
258
  readonly deactivate?: () => Promise<void>;
269
259
  /** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
@@ -278,37 +268,20 @@ export declare interface RateLimitStore {
278
268
  export declare const rateLimitTable: Table<"_voltro_ratelimit", FieldDefinitions<{
279
269
  readonly id: ColumnBuilder<number, "id", boolean>;
280
270
  readonly key: ColumnBuilder<string, "text", boolean>;
281
- readonly state: ColumnBuilder<BucketState | null, "json", boolean>;
271
+ readonly state: ColumnBuilder< HotRateLimitState | null, "json", boolean>;
282
272
  readonly touchedAt: ColumnBuilder<Date, "timestamp", true>;
283
273
  readonly touched: ColumnBuilder<unknown, "text", boolean>;
284
274
  }>, false, never>;
285
275
 
276
+ /** Redis limiter decisions go only through HotStore. The app owns every socket. */
286
277
  export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
287
278
 
288
279
  export declare interface RedisStoreOptions {
289
- /** Connection url. `resp` driver: `redis://…` / `rediss://…`. `http`
290
- * driver: the Upstash REST url. Falls back to `CACHE_REDIS_URL` /
291
- * `REDIS_URL` env at activate time. */
292
- readonly url?: string;
293
- /**
294
- * How to talk to the server — mirrors the cache plugin's axis:
295
- * - `'resp'` (default) — ioredis over TCP. Works for Redis, Valkey,
296
- * KeyDB, Dragonfly, and Upstash's TCP endpoint.
297
- * - `'http'` — `@upstash/redis` over REST, for serverless/edge where
298
- * TCP isn't available.
299
- * Resolved from `CACHE_REDIS_DRIVER` env when omitted. `'redis'` as a
300
- * store value is the umbrella for the whole RESP family — the server
301
- * brand is just the url; only the DRIVER is a real choice.
302
- */
303
- readonly driver?: 'resp' | 'http';
304
- /** Auth token — `http` (Upstash REST) driver only. Falls back to
305
- * `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` env. */
306
- readonly token?: string;
307
- /** Bring your own already-connected RESP client (from
308
- * `@voltro/kv/connection`'s `connect`, or the shared registry). Takes
309
- * precedence over `url` / `driver`. */
310
- readonly client?: RespClient;
311
- /** Key namespace. Default `'voltro:rl:'`. */
280
+ /** Registry reference. Omit to use the app's default Redis connection. */
281
+ readonly connection?: ConnectionRef<'redis'>;
282
+ /** Borrow a replacement HotStore port. Its owner retains disposal. */
283
+ readonly hotStore?: HotStoreShape;
284
+ /** HotStore namespace. Default 'voltro:rl'; cannot contain braces. */
312
285
  readonly keyPrefix?: string;
313
286
  }
314
287
 
package/dist/index.js CHANGED
@@ -6,9 +6,9 @@ import { RATELIMIT_TABLE as a, postgresStore as o, rateLimitTable as s } from ".
6
6
  import { redisStore as c } from "./redisStore.js";
7
7
  import { buildKey as l, limitFromOverride as u, matchRule as d, resolveLimit as f, toOverride as p } from "./resolve.js";
8
8
  import { Effect as m } from "effect";
9
- import { restError as h } from "@voltro/protocol/rest";
10
- import { createLogger as g } from "@voltro/logger";
11
- import { newTraceId as _ } from "@voltro/runtime";
9
+ import { newTraceId as h } from "@voltro/shared";
10
+ import { restError as g } from "@voltro/protocol/rest";
11
+ import { createLogger as _ } from "@voltro/logger";
12
12
  import { replicaEvidence as v } from "@voltro/protocol/identity";
13
13
  import { definePlugin as y, pluginInstanceName as b } from "@voltro/protocol";
14
14
  //#region src/index.ts
@@ -40,8 +40,9 @@ var x = (e) => ({
40
40
  "ratelimit-remaining": "0",
41
41
  "ratelimit-reset": String(Math.max(0, Math.ceil((t.resetAtMs - n) / 1e3)))
42
42
  };
43
- }, w = g({ scope: "voltro:ratelimit" }), T = (t = {}) => {
44
- let a = t.rules ?? [], g = t.store === void 0 || t.store === "memory" ? i() : t.store === "postgres" ? o() : t.store === "redis" ? c() : t.store, T = r(t, g), E = (e) => {
43
+ }, w = _({ scope: "voltro:ratelimit" }), T = (t = {}) => {
44
+ if (t.redis !== void 0 && t.store !== "redis") throw Error("Rate limiter redis options require store: redis");
45
+ let a = t.rules ?? [], _ = t.store === void 0 || t.store === "memory" ? i() : t.store === "postgres" ? o() : t.store === "redis" ? c(t.redis) : t.store, T = r(t, _), E = (e) => {
45
46
  let n = t.enabled;
46
47
  return n === void 0 ? !0 : typeof n == "function" ? n(e) : n;
47
48
  }, D = (e, n, r, i) => {
@@ -73,7 +74,7 @@ var x = (e) => ({
73
74
  return O(i, s).pipe(m.flatMap((t) => {
74
75
  if (t === "exempt") return T.bypass(), n;
75
76
  let r = l(o, i);
76
- return g.consume(r, t, Date.now()).pipe(m.catchDefect((e) => m.as(m.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
77
+ return _.consume(r, t, Date.now()).pipe(m.catchDefect((e) => m.as(m.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
77
78
  allowed: !0,
78
79
  failOpenReason: "store-error",
79
80
  remaining: t.limit,
@@ -95,7 +96,7 @@ var x = (e) => ({
95
96
  burst: i.limit
96
97
  }, o = r.remoteAddr ?? "unknown", s = Date.now(), c;
97
98
  try {
98
- c = await m.runPromise(g.consume(`http:ip:${o}`, a, s));
99
+ c = await m.runPromise(_.consume(`http:ip:${o}`, a, s));
99
100
  } catch (t) {
100
101
  return T.record("http", {
101
102
  allowed: !0,
@@ -144,38 +145,7 @@ var x = (e) => ({
144
145
  data: T.snapshot()
145
146
  }))
146
147
  }],
147
- declaredEnv: [
148
- {
149
- name: "CACHE_REDIS_URL",
150
- required: !1,
151
- secret: !0,
152
- description: "Redis connection URL for the redis store (credential-carrying). Falls back to REDIS_URL."
153
- },
154
- {
155
- name: "REDIS_URL",
156
- required: !1,
157
- secret: !0,
158
- description: "Fallback Redis connection URL for the redis store (credential-carrying)."
159
- },
160
- {
161
- name: "CACHE_REDIS_DRIVER",
162
- required: !1,
163
- secret: !1,
164
- description: "Redis driver selector: \"http\" for the Upstash REST client, otherwise RESP."
165
- },
166
- {
167
- name: "CACHE_REDIS_TOKEN",
168
- required: !1,
169
- secret: !0,
170
- description: "Upstash REST token for the http redis driver. Falls back to UPSTASH_REDIS_REST_TOKEN."
171
- },
172
- {
173
- name: "UPSTASH_REDIS_REST_TOKEN",
174
- required: !1,
175
- secret: !0,
176
- description: "Fallback Upstash REST token for the http redis driver."
177
- }
178
- ],
148
+ declaredEnv: [],
179
149
  ...A ? { onHttpRequest: A } : {},
180
150
  errorSchemas: [{
181
151
  schema: e,
@@ -193,11 +163,11 @@ var x = (e) => ({
193
163
  kind: "action",
194
164
  input: void 0,
195
165
  subject: n.subject,
196
- traceId: _(),
166
+ traceId: h(),
197
167
  surface: "rest",
198
168
  publicApi: { rateLimit: t },
199
169
  ...n.remoteAddr === void 0 ? {} : { remoteAddr: n.remoteAddr }
200
- }).pipe(m.catch((t) => m.fail(t instanceof e ? h(429, {
170
+ }).pipe(m.catch((t) => m.fail(t instanceof e ? g(429, {
201
171
  error: "RateLimited",
202
172
  message: "Rate limit exceeded"
203
173
  }, C(t)) : t))),
@@ -207,14 +177,14 @@ var x = (e) => ({
207
177
  interceptWorkflow: k,
208
178
  ...t.store === "postgres" ? { extendSchema: { tables: [s] } } : {},
209
179
  bindDataStore: (e) => {
210
- g.bind && (T.binding("binding"), g.bind(e).then(() => {
180
+ _.bind && (T.binding("binding"), _.bind(e).then(() => {
211
181
  N = void 0, T.binding("bound");
212
182
  }).catch((e) => {
213
183
  T.binding("failed"), N = e;
214
184
  }));
215
185
  },
216
186
  onActivate: (e) => m.gen(function* () {
217
- g.activate && (yield* m.promise(() => g.activate(e.env))), M = setInterval(() => g.sweep(Date.now()), 6e4), M.unref?.(), N !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: N instanceof Error ? N.message : String(N) });
187
+ _.activate && (yield* m.promise(() => _.activate(e.env, e.connectionRegistry))), M = setInterval(() => _.sweep(Date.now()), 6e4), M.unref?.(), N !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: N instanceof Error ? N.message : String(N) });
218
188
  let n = t.store === void 0 || t.store === "memory" ? v(e.env) : null;
219
189
  n !== null && e.logger.warn(`rate-limit: the counter store is PROCESS-LOCAL (the default), but ${n}, which means several replicas. Each pod counts on its own, so every limit is effectively multiplied by the number of replicas. Set \`store: 'redis'\` or \`store: 'postgres'\` to share the counters.`), e.logger.info("rate-limit active", {
220
190
  rules: a.length,
@@ -223,7 +193,7 @@ var x = (e) => ({
223
193
  });
224
194
  }),
225
195
  onDeactivate: () => m.gen(function* () {
226
- M && clearInterval(M), g.deactivate && (yield* m.promise(() => g.deactivate()));
196
+ M && clearInterval(M), _.deactivate && (yield* m.promise(() => _.deactivate()));
227
197
  })
228
198
  });
229
199
  };
package/dist/inspect.js CHANGED
@@ -41,7 +41,7 @@ var t = (e) => typeof e == "function" ? "dynamic" : e, n = (e) => typeof e == "f
41
41
  let e = n.size();
42
42
  Number.isFinite(e) && e >= 0 && (i = e);
43
43
  } catch {}
44
- let c = typeof t.store == "string" ? t.store : t.store === void 0 ? "memory" : "custom";
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
45
  return {
46
46
  capturedAt: Date.now(),
47
47
  startedAt: a,
@@ -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,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 };
package/dist/window.js CHANGED
@@ -1,117 +1,19 @@
1
+ import { consumeHotRateLimit as e } from "@voltro/kv/hot/contract";
1
2
  //#region src/window.ts
2
- var e = {
3
+ var t = {
3
4
  ms: 1,
4
5
  s: 1e3,
5
6
  m: 6e4,
6
7
  h: 36e5,
7
8
  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;
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;
12
13
  }
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
- };
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;
116
18
  //#endregion
117
- export { n as consumeBucket, t as parseWindow };
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.86.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,11 +48,12 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/database": "0.86.0",
52
- "@voltro/kv": "0.86.0",
53
- "@voltro/logger": "0.86.0",
54
- "@voltro/protocol": "0.86.0",
55
- "@voltro/runtime": "0.86.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"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "effect": "4.0.1"