@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.
- package/CHANGELOG.md +5987 -0
- package/THIRD-PARTY-NOTICES.md +4298 -258
- package/dist/index.d.ts +25 -51
- package/dist/index.js +103 -264
- package/dist/inspect.js +72 -0
- package/dist/postgresStore.d.ts +4 -19
- package/dist/postgresStore.js +48 -2
- package/dist/redisStore.d.ts +10 -25
- package/dist/redisStore.js +36 -32
- package/dist/resolve.js +65 -0
- package/dist/store.js +29 -0
- package/dist/window.js +19 -0
- package/package.json +7 -5
- package/dist/postgresStore-D_oHSGXq.js +0 -160
package/dist/inspect.js
ADDED
|
@@ -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 };
|
package/dist/postgresStore.d.ts
CHANGED
|
@@ -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<
|
|
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>;
|
package/dist/postgresStore.js
CHANGED
|
@@ -1,2 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 };
|
package/dist/redisStore.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
readonly
|
|
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
|
|
package/dist/redisStore.js
CHANGED
|
@@ -1,44 +1,48 @@
|
|
|
1
|
-
import { Effect as e } from "effect";
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
|
|
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
|
-
})
|
|
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) =>
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
48
|
+
export { o as redisStore };
|
package/dist/resolve.js
ADDED
|
@@ -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.
|
|
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/
|
|
52
|
-
"@voltro/
|
|
53
|
-
"@voltro/
|
|
54
|
-
"@voltro/
|
|
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 };
|