@voltro/plugin-ratelimit 0.82.0 → 0.83.1
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 +361 -0
- package/dist/index.d.ts +23 -8
- package/dist/index.js +112 -128
- package/dist/postgresStore-CDP61pxx.js +160 -0
- package/dist/postgresStore.d.ts +38 -8
- package/dist/postgresStore.js +2 -51
- package/dist/redisStore.d.ts +6 -6
- package/package.json +5 -4
- package/dist/window-lyQ6LXhj.js +0 -117
package/dist/index.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { RateLimited as e } from "./errors.js";
|
|
2
|
-
import {
|
|
3
|
-
import { redisStore as
|
|
4
|
-
import { Effect as
|
|
5
|
-
import { createLogger as
|
|
6
|
-
import { replicaEvidence as
|
|
7
|
-
import { definePlugin as
|
|
2
|
+
import { a as t, i as n, n as r, r as i, t as a } from "./postgresStore-CDP61pxx.js";
|
|
3
|
+
import { redisStore as o } from "./redisStore.js";
|
|
4
|
+
import { Effect as s } from "effect";
|
|
5
|
+
import { createLogger as c } from "@voltro/logger";
|
|
6
|
+
import { replicaEvidence as l } from "@voltro/protocol/identity";
|
|
7
|
+
import { definePlugin as u, pluginInstanceName as d } from "@voltro/protocol";
|
|
8
8
|
//#region src/inspect.ts
|
|
9
|
-
var
|
|
9
|
+
var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "function" ? "dynamic" : e, m = (e, n) => ({
|
|
10
10
|
id: n === null ? "default" : `r${n}`,
|
|
11
11
|
match: "match" in e ? e.match instanceof RegExp ? String(e.match) : Array.isArray(e.match) ? e.match.join(", ") : e.match ?? "*" : "*",
|
|
12
12
|
kinds: e.kind === void 0 ? [] : typeof e.kind == "string" ? [e.kind] : e.kind,
|
|
13
|
-
limit:
|
|
13
|
+
limit: f(e.limit),
|
|
14
14
|
windowMs: typeof e.window == "function" ? "dynamic" : t(e.window),
|
|
15
15
|
algorithm: e.algorithm ?? "sliding-window",
|
|
16
|
-
burst: e.burst === void 0 ?
|
|
17
|
-
by: Array.isArray(e.by) ? e.by.map(
|
|
16
|
+
burst: e.burst === void 0 ? f(e.limit) : f(e.burst),
|
|
17
|
+
by: Array.isArray(e.by) ? e.by.map(p) : [p(e.by ?? "subject")],
|
|
18
18
|
scope: e.scope ?? "tag",
|
|
19
19
|
tenantOverrides: Object.keys(e.tenants ?? {}).length
|
|
20
|
-
}),
|
|
20
|
+
}), h = () => ({
|
|
21
21
|
allowed: 0,
|
|
22
22
|
rejected: 0,
|
|
23
23
|
bypassed: 0,
|
|
@@ -25,11 +25,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
25
25
|
storeErrors: 0,
|
|
26
26
|
lastRejectedAt: null,
|
|
27
27
|
lastStoreErrorAt: null
|
|
28
|
-
}),
|
|
28
|
+
}), g = (e, n) => {
|
|
29
29
|
let r = Date.now(), i = {
|
|
30
|
-
rpc:
|
|
31
|
-
http:
|
|
32
|
-
}, a = n.
|
|
30
|
+
rpc: h(),
|
|
31
|
+
http: h()
|
|
32
|
+
}, a = n.bind ? "waiting" : "not-required";
|
|
33
33
|
return {
|
|
34
34
|
binding: (e) => {
|
|
35
35
|
a = e;
|
|
@@ -59,8 +59,8 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
59
59
|
sqlBinding: a,
|
|
60
60
|
trackedBuckets: o
|
|
61
61
|
},
|
|
62
|
-
rules: (e.rules ?? []).map((e, t) =>
|
|
63
|
-
defaultRule: e.default ?
|
|
62
|
+
rules: (e.rules ?? []).map((e, t) => m(e, t)),
|
|
63
|
+
defaultRule: e.default ? m(e.default, null) : null,
|
|
64
64
|
http: e.http ? {
|
|
65
65
|
limit: e.http.limit,
|
|
66
66
|
windowMs: t(e.http.window),
|
|
@@ -73,51 +73,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
|
-
},
|
|
77
|
-
let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5,
|
|
78
|
-
if (
|
|
79
|
-
let n =
|
|
80
|
-
for (let t of
|
|
76
|
+
}, _ = (e = {}) => {
|
|
77
|
+
let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, i = /* @__PURE__ */ new Map(), a = (e) => {
|
|
78
|
+
if (i.size <= t) return;
|
|
79
|
+
let n = i.size - t, r = 0;
|
|
80
|
+
for (let t of i.keys()) {
|
|
81
81
|
if (r >= n) break;
|
|
82
|
-
t !== e && (
|
|
82
|
+
t !== e && (i.delete(t), r += 1);
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
return {
|
|
86
|
-
consume: (e, t, r) =>
|
|
87
|
-
let
|
|
88
|
-
return
|
|
86
|
+
consume: (e, t, r) => s.sync(() => {
|
|
87
|
+
let o = i.get(e), { state: s, decision: c } = n(o?.state, t, r);
|
|
88
|
+
return i.set(e, {
|
|
89
89
|
state: s,
|
|
90
90
|
touched: r
|
|
91
|
-
}),
|
|
91
|
+
}), a(e), c;
|
|
92
92
|
}),
|
|
93
93
|
sweep: (e) => {
|
|
94
94
|
let t = e - r;
|
|
95
|
-
for (let [e, n] of
|
|
95
|
+
for (let [e, n] of i) n.touched < t && i.delete(e);
|
|
96
96
|
},
|
|
97
|
-
size: () =>
|
|
97
|
+
size: () => i.size
|
|
98
98
|
};
|
|
99
|
-
},
|
|
100
|
-
allowed: !0,
|
|
101
|
-
failOpenReason: "unbound",
|
|
102
|
-
remaining: e.limit,
|
|
103
|
-
resetAtMs: t + e.windowMs,
|
|
104
|
-
retryAfterMs: 0
|
|
105
|
-
}), g = (e = {}) => {
|
|
106
|
-
let t = null;
|
|
107
|
-
return {
|
|
108
|
-
consume: (e, n, r) => t ? t.consume(e, n, r) : i.succeed(h(n, r)),
|
|
109
|
-
sweep: (e) => t?.sweep(e),
|
|
110
|
-
size: () => t ? t.size() : -1,
|
|
111
|
-
bindSql: async (n) => {
|
|
112
|
-
let { postgresStore: r } = await import("./postgresStore.js"), i = r(e);
|
|
113
|
-
await i.bindSql(n), t = i;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
}, _ = (e, t) => typeof e == "function" ? e(t) : e, v = /* @__PURE__ */ new WeakMap(), y = (e) => {
|
|
99
|
+
}, v = (e, t) => typeof e == "function" ? e(t) : e, y = /* @__PURE__ */ new WeakMap(), b = (e) => {
|
|
117
100
|
if (!e.global && !e.sticky) return e;
|
|
118
|
-
let t =
|
|
119
|
-
return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")),
|
|
120
|
-
},
|
|
101
|
+
let t = y.get(e);
|
|
102
|
+
return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")), y.set(e, t)), t;
|
|
103
|
+
}, x = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : b(e).test(t), S = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, C = (e, t) => {
|
|
121
104
|
let n = t === null ? void 0 : e.tenants?.[t];
|
|
122
105
|
return {
|
|
123
106
|
limit: n?.limit ?? e.limit,
|
|
@@ -128,27 +111,27 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
128
111
|
scope: e.scope ?? "tag",
|
|
129
112
|
disabled: n?.disabled ?? !1
|
|
130
113
|
};
|
|
131
|
-
},
|
|
114
|
+
}, w = (e, t, n) => {
|
|
132
115
|
for (let t = 0; t < e.length; t++) {
|
|
133
116
|
let r = e[t];
|
|
134
|
-
if (
|
|
135
|
-
effective:
|
|
117
|
+
if (x(r.match, n.tag) && S(r.kind, n.kind)) return {
|
|
118
|
+
effective: C(r, n.tenantId),
|
|
136
119
|
ruleId: `r${t}`
|
|
137
120
|
};
|
|
138
121
|
}
|
|
139
|
-
if (t &&
|
|
140
|
-
effective:
|
|
122
|
+
if (t && S(t.kind, n.kind)) return {
|
|
123
|
+
effective: C(t, n.tenantId),
|
|
141
124
|
ruleId: "default"
|
|
142
125
|
};
|
|
143
|
-
},
|
|
144
|
-
let r =
|
|
126
|
+
}, T = (e, n) => {
|
|
127
|
+
let r = v(e.limit, n);
|
|
145
128
|
return {
|
|
146
129
|
limit: r,
|
|
147
|
-
windowMs: t(
|
|
130
|
+
windowMs: t(v(e.window, n)),
|
|
148
131
|
algorithm: e.algorithm ?? "sliding-window",
|
|
149
|
-
burst: e.burst === void 0 ? r :
|
|
132
|
+
burst: e.burst === void 0 ? r : v(e.burst, n)
|
|
150
133
|
};
|
|
151
|
-
},
|
|
134
|
+
}, E = (e) => {
|
|
152
135
|
let n = e.limit;
|
|
153
136
|
return {
|
|
154
137
|
limit: n,
|
|
@@ -156,12 +139,12 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
156
139
|
algorithm: e.algorithm ?? "sliding-window",
|
|
157
140
|
burst: e.burst ?? n
|
|
158
141
|
};
|
|
159
|
-
},
|
|
142
|
+
}, D = (e) => ({
|
|
160
143
|
limit: e.limit,
|
|
161
144
|
window: e.windowMs,
|
|
162
145
|
algorithm: e.algorithm,
|
|
163
146
|
burst: e.burst
|
|
164
|
-
}),
|
|
147
|
+
}), O = (e, t) => {
|
|
165
148
|
if (typeof e == "function") return e(t);
|
|
166
149
|
switch (e) {
|
|
167
150
|
case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
@@ -170,10 +153,10 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
170
153
|
case "global": return "g";
|
|
171
154
|
case "ip": return `i:${t.remoteAddr ?? "unknown"}`;
|
|
172
155
|
}
|
|
173
|
-
},
|
|
156
|
+
}, k = (e, t) => {
|
|
174
157
|
let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
|
|
175
|
-
return `${i}|${Array.isArray(a) ? a.map((e) =>
|
|
176
|
-
},
|
|
158
|
+
return `${i}|${Array.isArray(a) ? a.map((e) => O(e, t)).join("|") : O(a, t)}`;
|
|
159
|
+
}, A = (e) => ({
|
|
177
160
|
tag: e.tag,
|
|
178
161
|
kind: e.kind,
|
|
179
162
|
subject: e.subject,
|
|
@@ -182,7 +165,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
182
165
|
...e.surface === void 0 ? {} : { surface: e.surface },
|
|
183
166
|
...e.remoteAddr === void 0 ? {} : { remoteAddr: e.remoteAddr },
|
|
184
167
|
...e.surface === "rest" && e.publicApi?.rateLimit !== void 0 ? { publicApiRateLimit: e.publicApi.rateLimit } : {}
|
|
185
|
-
}),
|
|
168
|
+
}), j = (e) => e.publicApiRateLimit === void 0 ? void 0 : {
|
|
186
169
|
ruleId: "publicApi",
|
|
187
170
|
effective: {
|
|
188
171
|
limit: e.publicApiRateLimit.limit,
|
|
@@ -193,7 +176,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
193
176
|
scope: "tag",
|
|
194
177
|
disabled: !1
|
|
195
178
|
}
|
|
196
|
-
},
|
|
179
|
+
}, M = (e) => {
|
|
197
180
|
let t = e, n = Date.now();
|
|
198
181
|
return {
|
|
199
182
|
"retry-after": String(Math.max(1, Math.ceil(t.retryAfterMs / 1e3))),
|
|
@@ -201,11 +184,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
201
184
|
"ratelimit-remaining": "0",
|
|
202
185
|
"ratelimit-reset": String(Math.max(0, Math.ceil((t.resetAtMs - n) / 1e3)))
|
|
203
186
|
};
|
|
204
|
-
},
|
|
205
|
-
let a = n.rules ?? [],
|
|
187
|
+
}, N = c({ scope: "voltro:ratelimit" }), P = (n = {}) => {
|
|
188
|
+
let a = n.rules ?? [], c = n.store === void 0 || n.store === "memory" ? _() : n.store === "postgres" ? r() : n.store === "redis" ? o() : n.store, f = g(n, c), p = (e) => {
|
|
206
189
|
let t = n.enabled;
|
|
207
190
|
return t === void 0 ? !0 : typeof t == "function" ? t(e) : t;
|
|
208
|
-
},
|
|
191
|
+
}, m = (e, t, r, i) => {
|
|
209
192
|
if (!n.onLimited) return;
|
|
210
193
|
let a = {
|
|
211
194
|
tag: e.tag,
|
|
@@ -221,87 +204,88 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
221
204
|
} catch {}
|
|
222
205
|
}, h = (e, t) => {
|
|
223
206
|
let r = n.resolve;
|
|
224
|
-
return r ?
|
|
207
|
+
return r ? s.suspend(() => {
|
|
225
208
|
let n = r(e, t);
|
|
226
|
-
return (
|
|
227
|
-
}) :
|
|
228
|
-
},
|
|
229
|
-
let
|
|
230
|
-
if (!
|
|
231
|
-
let
|
|
232
|
-
if (!
|
|
233
|
-
let
|
|
234
|
-
return h(
|
|
235
|
-
if (n === "exempt") return
|
|
236
|
-
let r =
|
|
237
|
-
return
|
|
209
|
+
return (s.isEffect(n) ? n : n instanceof Promise ? s.promise(() => n) : s.succeed(n)).pipe(s.map((e) => e === "exempt" ? "exempt" : E(e === void 0 ? t : e)));
|
|
210
|
+
}) : s.succeed(E(t));
|
|
211
|
+
}, v = (t, r) => s.suspend(() => {
|
|
212
|
+
let i = A(r);
|
|
213
|
+
if (!p(i)) return f.bypass(), t;
|
|
214
|
+
let o = j(i) ?? w(a, n.default, i);
|
|
215
|
+
if (!o || o.effective.disabled) return f.bypass(), t;
|
|
216
|
+
let l = D(T(o.effective, i));
|
|
217
|
+
return h(i, l).pipe(s.flatMap((n) => {
|
|
218
|
+
if (n === "exempt") return f.bypass(), t;
|
|
219
|
+
let r = k(o, i);
|
|
220
|
+
return c.consume(r, n, Date.now()).pipe(s.catchAllDefect((e) => s.as(s.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
|
|
238
221
|
allowed: !0,
|
|
239
222
|
failOpenReason: "store-error",
|
|
240
223
|
remaining: n.limit,
|
|
241
224
|
resetAtMs: Date.now(),
|
|
242
225
|
retryAfterMs: 0
|
|
243
|
-
})),
|
|
244
|
-
tag:
|
|
226
|
+
})), s.tap((e) => s.sync(() => f.record("rpc", e))), s.flatMap((a) => a.allowed ? t : s.zipRight(s.sync(() => m(i, r, n, a)), s.fail(new e({
|
|
227
|
+
tag: i.tag,
|
|
245
228
|
limit: n.limit,
|
|
246
229
|
windowMs: n.windowMs,
|
|
247
230
|
retryAfterMs: a.retryAfterMs,
|
|
248
231
|
resetAtMs: a.resetAtMs
|
|
249
232
|
})))));
|
|
250
233
|
}));
|
|
251
|
-
}),
|
|
252
|
-
let
|
|
253
|
-
limit:
|
|
254
|
-
windowMs: t(
|
|
255
|
-
algorithm:
|
|
256
|
-
burst:
|
|
257
|
-
},
|
|
234
|
+
}), y = n.http ? async (e, r) => {
|
|
235
|
+
let i = n.http, a = {
|
|
236
|
+
limit: i.limit,
|
|
237
|
+
windowMs: t(i.window),
|
|
238
|
+
algorithm: i.algorithm ?? "fixed-window",
|
|
239
|
+
burst: i.limit
|
|
240
|
+
}, o = r.remoteAddr ?? "unknown", l = Date.now(), u;
|
|
258
241
|
try {
|
|
259
|
-
|
|
242
|
+
u = await s.runPromise(c.consume(`http:ip:${o}`, a, l));
|
|
260
243
|
} catch (t) {
|
|
261
|
-
return
|
|
244
|
+
return f.record("http", {
|
|
262
245
|
allowed: !0,
|
|
263
246
|
failOpenReason: "store-error",
|
|
264
247
|
remaining: 0,
|
|
265
|
-
resetAtMs:
|
|
248
|
+
resetAtMs: l,
|
|
266
249
|
retryAfterMs: 0
|
|
267
|
-
}),
|
|
250
|
+
}), N.warn("http shield store failure — request ALLOWED without a limit check", { cause: String(t) }), e();
|
|
268
251
|
}
|
|
269
|
-
|
|
270
|
-
let
|
|
271
|
-
return
|
|
252
|
+
f.record("http", u);
|
|
253
|
+
let d = String(Math.max(0, Math.ceil((u.resetAtMs - l) / 1e3)));
|
|
254
|
+
return u.allowed ? e() : {
|
|
272
255
|
status: 429,
|
|
273
256
|
body: "Too Many Requests",
|
|
274
257
|
headers: {
|
|
275
258
|
"content-type": "text/plain; charset=utf-8",
|
|
276
|
-
"retry-after": String(Math.max(1, Math.ceil(
|
|
277
|
-
"ratelimit-limit": String(
|
|
278
|
-
"ratelimit-remaining": String(Math.max(0,
|
|
279
|
-
"ratelimit-reset":
|
|
259
|
+
"retry-after": String(Math.max(1, Math.ceil(u.retryAfterMs / 1e3))),
|
|
260
|
+
"ratelimit-limit": String(a.limit),
|
|
261
|
+
"ratelimit-remaining": String(Math.max(0, u.remaining)),
|
|
262
|
+
"ratelimit-reset": d
|
|
280
263
|
}
|
|
281
264
|
};
|
|
282
|
-
} : void 0,
|
|
265
|
+
} : void 0, b = [
|
|
283
266
|
"inspect:read",
|
|
284
267
|
"rpc:intercept:mutation",
|
|
285
268
|
"rpc:intercept:query",
|
|
286
269
|
"rpc:intercept:action",
|
|
287
270
|
"rpc:intercept:workflow",
|
|
288
|
-
...n.http ? ["http:intercept"] : []
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
271
|
+
...n.http ? ["http:intercept"] : [],
|
|
272
|
+
...n.store === "postgres" ? ["store:write"] : []
|
|
273
|
+
], x, S;
|
|
274
|
+
return u({
|
|
275
|
+
name: d({
|
|
292
276
|
base: "@voltro/plugin-ratelimit",
|
|
293
277
|
instance: n.name
|
|
294
278
|
}),
|
|
295
279
|
description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
|
|
296
|
-
permissions:
|
|
280
|
+
permissions: b,
|
|
297
281
|
inspectEndpoints: [{
|
|
298
282
|
method: "GET",
|
|
299
283
|
path: "/state",
|
|
300
284
|
scope: "process",
|
|
301
285
|
description: "Declared rules and process-local limiting evidence, without bucket identities.",
|
|
302
|
-
handler: () =>
|
|
286
|
+
handler: () => s.sync(() => ({
|
|
303
287
|
kind: "json",
|
|
304
|
-
data:
|
|
288
|
+
data: f.snapshot()
|
|
305
289
|
}))
|
|
306
290
|
}],
|
|
307
291
|
declaredEnv: [
|
|
@@ -336,7 +320,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
336
320
|
description: "Fallback Upstash REST token for the http redis driver."
|
|
337
321
|
}
|
|
338
322
|
],
|
|
339
|
-
...
|
|
323
|
+
...y ? { onHttpRequest: y } : {},
|
|
340
324
|
errorSchemas: [{
|
|
341
325
|
schema: e,
|
|
342
326
|
import: {
|
|
@@ -345,34 +329,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
345
329
|
},
|
|
346
330
|
rest: {
|
|
347
331
|
status: 429,
|
|
348
|
-
headers:
|
|
332
|
+
headers: M
|
|
349
333
|
}
|
|
350
334
|
}],
|
|
351
|
-
interceptMutation:
|
|
352
|
-
interceptQuery:
|
|
353
|
-
interceptAction:
|
|
354
|
-
interceptWorkflow:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
335
|
+
interceptMutation: v,
|
|
336
|
+
interceptQuery: v,
|
|
337
|
+
interceptAction: v,
|
|
338
|
+
interceptWorkflow: v,
|
|
339
|
+
...n.store === "postgres" ? { extendSchema: { tables: [i] } } : {},
|
|
340
|
+
bindDataStore: (e) => {
|
|
341
|
+
c.bind && (f.binding("binding"), c.bind(e).then(() => {
|
|
342
|
+
S = void 0, f.binding("bound");
|
|
359
343
|
}).catch((e) => {
|
|
360
|
-
|
|
344
|
+
f.binding("failed"), S = e;
|
|
361
345
|
}));
|
|
362
346
|
},
|
|
363
|
-
onActivate: (e) =>
|
|
364
|
-
|
|
365
|
-
let t = n.store === void 0 || n.store === "memory" ?
|
|
347
|
+
onActivate: (e) => s.gen(function* () {
|
|
348
|
+
c.activate && (yield* s.promise(() => c.activate(e.env))), x = setInterval(() => c.sweep(Date.now()), 6e4), x.unref?.(), S !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: S instanceof Error ? S.message : String(S) });
|
|
349
|
+
let t = n.store === void 0 || n.store === "memory" ? l(e.env) : null;
|
|
366
350
|
t !== null && e.logger.warn(`rate-limit: the counter store is PROCESS-LOCAL (the default), but ${t}, 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", {
|
|
367
351
|
rules: a.length,
|
|
368
352
|
hasDefault: n.default !== void 0,
|
|
369
353
|
store: typeof n.store == "string" ? n.store : n.store === void 0 ? "memory" : "custom"
|
|
370
354
|
});
|
|
371
355
|
}),
|
|
372
|
-
onDeactivate: () =>
|
|
373
|
-
|
|
356
|
+
onDeactivate: () => s.gen(function* () {
|
|
357
|
+
x && clearInterval(x), c.deactivate && (yield* s.promise(() => c.deactivate()));
|
|
374
358
|
})
|
|
375
359
|
});
|
|
376
360
|
};
|
|
377
361
|
//#endregion
|
|
378
|
-
export { e as RateLimited, n as consumeBucket,
|
|
362
|
+
export { a as RATELIMIT_TABLE, e as RateLimited, n as consumeBucket, _ as memoryStore, t as parseWindow, r as postgresStore, P as rateLimitPlugin, i as rateLimitTable, o as redisStore };
|
|
@@ -0,0 +1,160 @@
|
|
|
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.catchAll(() => 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 };
|
package/dist/postgresStore.d.ts
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
|
+
import { ColumnBuilder } from '@voltro/database';
|
|
2
|
+
import { DataStore } from '@voltro/database';
|
|
1
3
|
import { Effect } from 'effect';
|
|
2
|
-
import {
|
|
4
|
+
import { FieldDefinitions } from '@voltro/database';
|
|
5
|
+
import { Table } from '@voltro/database';
|
|
3
6
|
|
|
4
7
|
declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
5
8
|
|
|
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
|
+
|
|
6
26
|
export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
|
|
7
27
|
|
|
8
28
|
export declare interface PostgresStoreOptions {
|
|
9
|
-
/** Table name. Default `_voltro_ratelimit`. */
|
|
10
|
-
readonly table?: string;
|
|
11
29
|
/** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
|
|
12
30
|
readonly staleAfterMs?: number;
|
|
13
31
|
}
|
|
14
32
|
|
|
33
|
+
export declare const RATELIMIT_TABLE = "_voltro_ratelimit";
|
|
34
|
+
|
|
15
35
|
declare interface RateLimitDecision {
|
|
16
36
|
readonly allowed: boolean;
|
|
17
37
|
/** The request passed without a quota check because the store was unavailable.
|
|
@@ -38,13 +58,23 @@ declare interface RateLimitStore {
|
|
|
38
58
|
readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
|
|
39
59
|
/** Optional teardown at plugin deactivate (close pools). */
|
|
40
60
|
readonly deactivate?: () => Promise<void>;
|
|
41
|
-
/** Optional bind of the
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
readonly
|
|
61
|
+
/** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
|
|
62
|
+
* A store kept in the app's database reads and writes through it — its
|
|
63
|
+
* table is declared through `extendSchema`, never created here. Not
|
|
64
|
+
* present on memory / redis stores. */
|
|
65
|
+
readonly bind?: (store: DataStore) => Promise<void>;
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
/** One row per rate-limit key. `id` + a unique `key` rather than `key` as the
|
|
69
|
+
* primary key: every dialect store addresses a row by `id`. */
|
|
70
|
+
export declare const rateLimitTable: Table<"_voltro_ratelimit", FieldDefinitions<{
|
|
71
|
+
readonly id: ColumnBuilder<number, "id", boolean>;
|
|
72
|
+
readonly key: ColumnBuilder<string, "text", boolean>;
|
|
73
|
+
readonly state: ColumnBuilder<BucketState | null, "json", boolean>;
|
|
74
|
+
readonly touchedAt: ColumnBuilder<Date, "timestamp", true>;
|
|
75
|
+
readonly touched: ColumnBuilder<unknown, "text", boolean>;
|
|
76
|
+
}>, false, never>;
|
|
77
|
+
|
|
48
78
|
/** The fully-resolved limit the store consumes against. All dynamic /
|
|
49
79
|
* per-tenant resolution has already collapsed into these scalars. */
|
|
50
80
|
declare interface ResolvedLimit {
|