@voltro/plugin-ratelimit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +827 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +319 -0
- package/dist/index.js +243 -0
- package/dist/postgresStore.d.ts +55 -0
- package/dist/postgresStore.js +50 -0
- package/dist/redisStore.d.ts +76 -0
- package/dist/redisStore.js +39 -0
- package/dist/window-lyQ6LXhj.js +117 -0
- package/package.json +62 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Raised on the RPC error channel when a call exceeds its limit. The
|
|
5
|
+
* interceptor fails with this; the framework's rpc encoder carries it.
|
|
6
|
+
*
|
|
7
|
+
* The plugin registers this schema through `errorSchemas` (see
|
|
8
|
+
* `index.ts`), so it is merged into EVERY procedure's wire error union —
|
|
9
|
+
* the client decodes it as a typed `RateLimited` instance (catchable via
|
|
10
|
+
* `Effect.catchTag('RateLimited', …)` or `instanceof RateLimited`) on any
|
|
11
|
+
* limited call, with no per-route `error:` opt-in required.
|
|
12
|
+
*
|
|
13
|
+
* Import it from the browser-safe `@voltro/plugin-ratelimit/errors`
|
|
14
|
+
* subpath when a descriptor names it in its own `error:` schema — this
|
|
15
|
+
* file imports only `effect` (no server modules), so it's safe in a
|
|
16
|
+
* client bundle.
|
|
17
|
+
*/
|
|
18
|
+
export declare class RateLimited extends RateLimited_base {
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare const RateLimited_base: Schema.TaggedErrorClass<RateLimited, "RateLimited", {
|
|
22
|
+
readonly _tag: Schema.tag<"RateLimited">;
|
|
23
|
+
} & {
|
|
24
|
+
/** The rpc tag that was limited, e.g. `'todos.create'`. */
|
|
25
|
+
tag: typeof Schema.String;
|
|
26
|
+
/** The effective limit that was hit. */
|
|
27
|
+
limit: typeof Schema.Number;
|
|
28
|
+
/** The window the limit applies over, in ms. */
|
|
29
|
+
windowMs: typeof Schema.Number;
|
|
30
|
+
/** Suggested wait before retrying, in ms. */
|
|
31
|
+
retryAfterMs: typeof Schema.Number;
|
|
32
|
+
/** Epoch-ms when the bucket is expected to be replenished. */
|
|
33
|
+
resetAtMs: typeof Schema.Number;
|
|
34
|
+
}>;
|
|
35
|
+
|
|
36
|
+
export { }
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Schema as e } from "effect";
|
|
2
|
+
//#region src/errors.ts
|
|
3
|
+
var t = class extends e.TaggedError()("RateLimited", {
|
|
4
|
+
tag: e.String,
|
|
5
|
+
limit: e.Number,
|
|
6
|
+
windowMs: e.Number,
|
|
7
|
+
retryAfterMs: e.Number,
|
|
8
|
+
resetAtMs: e.Number
|
|
9
|
+
}) {};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { t as RateLimited };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { RespClient } from '@voltro/kv/connection';
|
|
3
|
+
import { RpcKind } from '@voltro/protocol';
|
|
4
|
+
import { Schema } from 'effect';
|
|
5
|
+
import { SqlClient } from '@effect/sql';
|
|
6
|
+
import { Subject } from '@voltro/protocol';
|
|
7
|
+
import { VoltroPlugin } from '@voltro/protocol';
|
|
8
|
+
|
|
9
|
+
export declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
10
|
+
|
|
11
|
+
declare interface BucketResult {
|
|
12
|
+
readonly state: BucketState;
|
|
13
|
+
readonly decision: RateLimitDecision;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Opaque per-key state. The store persists it verbatim; only this
|
|
17
|
+
* module reads its shape. */
|
|
18
|
+
export declare type BucketState = {
|
|
19
|
+
readonly kind: 'fixed';
|
|
20
|
+
readonly windowStart: number;
|
|
21
|
+
readonly count: number;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'sliding';
|
|
24
|
+
readonly currStart: number;
|
|
25
|
+
readonly currCount: number;
|
|
26
|
+
readonly prevCount: number;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: 'token';
|
|
29
|
+
readonly tokens: number;
|
|
30
|
+
readonly lastRefill: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export declare const consumeBucket: (prev: BucketState | undefined, limit: ResolvedLimit, now: number) => BucketResult;
|
|
34
|
+
|
|
35
|
+
/** The fallback rule applied when no `rules` entry matches. Same shape
|
|
36
|
+
* minus `match` (it matches whatever's left). Omit to leave unmatched
|
|
37
|
+
* calls unlimited. */
|
|
38
|
+
export declare type DefaultRule = Omit<RateLimitRule, 'match'>;
|
|
39
|
+
|
|
40
|
+
/** A value that can be a constant or computed per-call. Dynamic form
|
|
41
|
+
* receives the resolution context (subject, tenant, tag). */
|
|
42
|
+
export declare type Dynamic<T> = T | ((ctx: RateLimitContext) => T);
|
|
43
|
+
|
|
44
|
+
/** Coarse pre-auth IP shield on the HTTP pipeline. */
|
|
45
|
+
export declare interface HttpShieldOptions {
|
|
46
|
+
readonly limit: number;
|
|
47
|
+
readonly window: WindowSpec;
|
|
48
|
+
/** Default `'fixed-window'` — cheapest for a front-door guard. */
|
|
49
|
+
readonly algorithm?: Algorithm;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What to key the bucket on. Composite arrays join into one key, so
|
|
53
|
+
* `['tenant', 'subject']` = one bucket per (tenant, user). */
|
|
54
|
+
export declare type KeyBy = 'subject' | 'tenant' | 'apiKey' | 'global' | ((ctx: RateLimitContext) => string);
|
|
55
|
+
|
|
56
|
+
/** Diagnostics passed to the optional `onLimited` callback. */
|
|
57
|
+
export declare interface LimitedInfo {
|
|
58
|
+
readonly tag: string;
|
|
59
|
+
readonly kind: RpcKind;
|
|
60
|
+
readonly key: string;
|
|
61
|
+
readonly tenantId: string | null;
|
|
62
|
+
readonly limit: number;
|
|
63
|
+
readonly windowMs: number;
|
|
64
|
+
readonly retryAfterMs: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export declare const memoryStore: (options?: MemoryStoreOptions) => RateLimitStore;
|
|
68
|
+
|
|
69
|
+
export declare interface MemoryStoreOptions {
|
|
70
|
+
/** Hard cap on tracked keys. Oldest-touched entries are evicted past
|
|
71
|
+
* this. Default 100k — a runaway-key backstop, not a tuning knob. */
|
|
72
|
+
readonly maxKeys?: number;
|
|
73
|
+
/** Entries untouched for longer than this (ms) are dropped on sweep.
|
|
74
|
+
* Default 1h. */
|
|
75
|
+
readonly staleAfterMs?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse a `WindowSpec` to milliseconds. Throws on malformed input — a
|
|
79
|
+
* bad window is a config error that should surface loudly at boot. */
|
|
80
|
+
export declare const parseWindow: (spec: WindowSpec) => number;
|
|
81
|
+
|
|
82
|
+
export declare interface PostgresStoreOptions {
|
|
83
|
+
/** Table name. Default `_voltro_ratelimit`. */
|
|
84
|
+
readonly table?: string;
|
|
85
|
+
/** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
|
|
86
|
+
readonly staleAfterMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The context handed to every dynamic option + the `resolve` hook.
|
|
90
|
+
* Derived from the framework's RpcInterceptorContext. */
|
|
91
|
+
export declare interface RateLimitContext {
|
|
92
|
+
readonly tag: string;
|
|
93
|
+
readonly kind: RpcKind;
|
|
94
|
+
readonly subject: Subject;
|
|
95
|
+
/** Convenience: the subject's tenant (`null` only for unscoped anonymous). */
|
|
96
|
+
readonly tenantId: string | null;
|
|
97
|
+
readonly traceId: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export declare interface RateLimitDecision {
|
|
101
|
+
readonly allowed: boolean;
|
|
102
|
+
/** Remaining allowance in the current window (best-effort estimate). */
|
|
103
|
+
readonly remaining: number;
|
|
104
|
+
/** Epoch-ms when the bucket is expected to be fully replenished. */
|
|
105
|
+
readonly resetAtMs: number;
|
|
106
|
+
/** How long the caller should wait before retrying. 0 when allowed. */
|
|
107
|
+
readonly retryAfterMs: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Raised on the RPC error channel when a call exceeds its limit. The
|
|
112
|
+
* interceptor fails with this; the framework's rpc encoder carries it.
|
|
113
|
+
*
|
|
114
|
+
* The plugin registers this schema through `errorSchemas` (see
|
|
115
|
+
* `index.ts`), so it is merged into EVERY procedure's wire error union —
|
|
116
|
+
* the client decodes it as a typed `RateLimited` instance (catchable via
|
|
117
|
+
* `Effect.catchTag('RateLimited', …)` or `instanceof RateLimited`) on any
|
|
118
|
+
* limited call, with no per-route `error:` opt-in required.
|
|
119
|
+
*
|
|
120
|
+
* Import it from the browser-safe `@voltro/plugin-ratelimit/errors`
|
|
121
|
+
* subpath when a descriptor names it in its own `error:` schema — this
|
|
122
|
+
* file imports only `effect` (no server modules), so it's safe in a
|
|
123
|
+
* client bundle.
|
|
124
|
+
*/
|
|
125
|
+
export declare class RateLimited extends RateLimited_base {
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
declare const RateLimited_base: Schema.TaggedErrorClass<RateLimited, "RateLimited", {
|
|
129
|
+
readonly _tag: Schema.tag<"RateLimited">;
|
|
130
|
+
} & {
|
|
131
|
+
/** The rpc tag that was limited, e.g. `'todos.create'`. */
|
|
132
|
+
tag: typeof Schema.String;
|
|
133
|
+
/** The effective limit that was hit. */
|
|
134
|
+
limit: typeof Schema.Number;
|
|
135
|
+
/** The window the limit applies over, in ms. */
|
|
136
|
+
windowMs: typeof Schema.Number;
|
|
137
|
+
/** Suggested wait before retrying, in ms. */
|
|
138
|
+
retryAfterMs: typeof Schema.Number;
|
|
139
|
+
/** Epoch-ms when the bucket is expected to be replenished. */
|
|
140
|
+
resetAtMs: typeof Schema.Number;
|
|
141
|
+
}>;
|
|
142
|
+
|
|
143
|
+
export declare const rateLimitPlugin: (options?: RateLimitPluginOptions) => VoltroPlugin;
|
|
144
|
+
|
|
145
|
+
export declare interface RateLimitPluginOptions {
|
|
146
|
+
/** Ordered, per-endpoint rules. First match wins. */
|
|
147
|
+
readonly rules?: ReadonlyArray<RateLimitRule>;
|
|
148
|
+
/**
|
|
149
|
+
* Coarse PRE-AUTH IP shield, keyed by remote IP. Fires on EVERY HTTP
|
|
150
|
+
* request — inspect, webhooks, AND the rpc websocket upgrade — before
|
|
151
|
+
* any subject is resolved. A cheap DoS front-door in addition to the
|
|
152
|
+
* per-subject rpc `rules`. Shares the same `store`. Omit to disable.
|
|
153
|
+
* Requires the `http:intercept` permission, which the plugin declares
|
|
154
|
+
* automatically when this is set.
|
|
155
|
+
*/
|
|
156
|
+
readonly http?: HttpShieldOptions;
|
|
157
|
+
/** Fallback for calls no rule matched. Omit → unmatched calls pass. */
|
|
158
|
+
readonly default?: DefaultRule;
|
|
159
|
+
/**
|
|
160
|
+
* Counter backend:
|
|
161
|
+
* - `'memory'` (default) — single-node, zero-config.
|
|
162
|
+
* - `'postgres'` — multi-node correct; opens its own pool from the
|
|
163
|
+
* same `DB_*`/`PG_*` env the framework reads, creates
|
|
164
|
+
* `_voltro_ratelimit`, mutates state under a row lock. Fails OPEN
|
|
165
|
+
* on db errors (degrades to no-limit, never to 500s).
|
|
166
|
+
* - `'redis'` — multi-node, fastest; connects ioredis from
|
|
167
|
+
* `CACHE_REDIS_URL`/`REDIS_URL`, runs the whole decision in one
|
|
168
|
+
* atomic Lua script. For a custom client/url use the
|
|
169
|
+
* `redisStore({...})` factory from `@voltro/plugin-ratelimit/redis`.
|
|
170
|
+
* - a custom `RateLimitStore`.
|
|
171
|
+
*/
|
|
172
|
+
readonly store?: 'memory' | 'postgres' | 'redis' | RateLimitStore;
|
|
173
|
+
/**
|
|
174
|
+
* Master gate. `false` disables all limiting; a function lets you gate
|
|
175
|
+
* per-call (e.g. exempt a tenant, or a maintenance bypass). Returning
|
|
176
|
+
* `false` means "no limit for this call". Default: enabled.
|
|
177
|
+
*/
|
|
178
|
+
readonly enabled?: boolean | ((ctx: RateLimitContext) => boolean);
|
|
179
|
+
/**
|
|
180
|
+
* Fully-programmatic per-tenant resolution. Runs AFTER a rule + its
|
|
181
|
+
* static tenant override are resolved; whatever it returns wins. Use
|
|
182
|
+
* for limits sourced from a tenant's plan/tier (e.g. read
|
|
183
|
+
* `subject.metadata.plan`, or look the tenant up — return an Effect).
|
|
184
|
+
*
|
|
185
|
+
* - return a `ResolvedRuleOverride` → override this call's limit
|
|
186
|
+
* - return `'exempt'` → skip limiting this call
|
|
187
|
+
* - return `undefined` → keep the rule-derived limit
|
|
188
|
+
*
|
|
189
|
+
* Sync, Promise, or Effect — all three are normalised.
|
|
190
|
+
*/
|
|
191
|
+
readonly resolve?: (ctx: RateLimitContext, base: ResolvedRuleOverride | undefined) => ResolvedRuleOverride | 'exempt' | undefined | Promise<ResolvedRuleOverride | 'exempt' | undefined> | Effect.Effect<ResolvedRuleOverride | 'exempt' | undefined>;
|
|
192
|
+
/** Fired (best-effort, swallowed on throw) whenever a call is rejected. */
|
|
193
|
+
readonly onLimited?: (info: LimitedInfo) => void;
|
|
194
|
+
/** Disambiguates multiple instances of this plugin in one app. */
|
|
195
|
+
readonly name?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One rate-limit rule. Rules are evaluated top-to-bottom; the first
|
|
199
|
+
* whose `match` + `kind` accept the call wins. */
|
|
200
|
+
export declare interface RateLimitRule {
|
|
201
|
+
/**
|
|
202
|
+
* Which endpoints this rule covers:
|
|
203
|
+
* - omitted → every tag (subject to `kind`)
|
|
204
|
+
* - string → exact rpc tag, e.g. `'todos.create'`
|
|
205
|
+
* - string[] → any of these exact tags
|
|
206
|
+
* - RegExp → tags matching the pattern, e.g. `/^admin\./`
|
|
207
|
+
*/
|
|
208
|
+
readonly match?: string | ReadonlyArray<string> | RegExp;
|
|
209
|
+
/** Restrict to certain rpc kinds (limit writes but not reads, etc.).
|
|
210
|
+
* Omitted → all kinds. */
|
|
211
|
+
readonly kind?: RpcKind | ReadonlyArray<RpcKind>;
|
|
212
|
+
/** Allowance per window. Static, or `(ctx) => n` for per-tenant/plan logic. */
|
|
213
|
+
readonly limit: Dynamic<number>;
|
|
214
|
+
/** Window length. Static or dynamic. */
|
|
215
|
+
readonly window: Dynamic<WindowSpec>;
|
|
216
|
+
/** Algorithm. Default `'sliding-window'`. */
|
|
217
|
+
readonly algorithm?: Algorithm;
|
|
218
|
+
/** Token-bucket burst capacity. Defaults to `limit`. Ignored by the
|
|
219
|
+
* window algorithms. */
|
|
220
|
+
readonly burst?: Dynamic<number>;
|
|
221
|
+
/** Keying dimension(s). Default `'subject'`. */
|
|
222
|
+
readonly by?: KeyBy | ReadonlyArray<KeyBy>;
|
|
223
|
+
/**
|
|
224
|
+
* Bucket granularity across the tags this rule matches:
|
|
225
|
+
* - `'tag'` (default) — a separate bucket per matched endpoint, so
|
|
226
|
+
* `todos.create` and `todos.update` count independently.
|
|
227
|
+
* - `'rule'` — one shared bucket across ALL tags the rule matches
|
|
228
|
+
* (e.g. "200 writes/min total across every mutation").
|
|
229
|
+
*/
|
|
230
|
+
readonly scope?: 'tag' | 'rule';
|
|
231
|
+
/** Static per-tenant overrides, keyed by tenantId. */
|
|
232
|
+
readonly tenants?: Readonly<Record<string, TenantOverride>>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export declare interface RateLimitStore {
|
|
236
|
+
/** Atomically consume one unit for `key` against `limit` at `nowMs`. */
|
|
237
|
+
readonly consume: (key: string, limit: ResolvedLimit, nowMs: number) => Effect.Effect<RateLimitDecision>;
|
|
238
|
+
/** Drop entries untouched long enough to be irrelevant. Called on a timer. */
|
|
239
|
+
readonly sweep: (nowMs: number) => void;
|
|
240
|
+
/** Current number of tracked keys (diagnostics / tests). `-1` when the
|
|
241
|
+
* store can't answer synchronously (e.g. postgres). */
|
|
242
|
+
readonly size: () => number;
|
|
243
|
+
/** Optional one-shot setup at plugin activate (open pools, create
|
|
244
|
+
* tables). Receives the process env. */
|
|
245
|
+
readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
|
|
246
|
+
/** Optional teardown at plugin deactivate (close pools). */
|
|
247
|
+
readonly deactivate?: () => Promise<void>;
|
|
248
|
+
/** Optional bind of the framework's already-open `SqlClient` (from the
|
|
249
|
+
* plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
|
|
250
|
+
* runs through THIS client instead of standing up its own pool; creates
|
|
251
|
+
* its bookkeeping table here. Not present on memory / redis stores. */
|
|
252
|
+
readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
|
|
256
|
+
|
|
257
|
+
export declare interface RedisStoreOptions {
|
|
258
|
+
/** Connection url. `resp` driver: `redis://…` / `rediss://…`. `http`
|
|
259
|
+
* driver: the Upstash REST url. Falls back to `CACHE_REDIS_URL` /
|
|
260
|
+
* `REDIS_URL` env at activate time. */
|
|
261
|
+
readonly url?: string;
|
|
262
|
+
/**
|
|
263
|
+
* How to talk to the server — mirrors the cache plugin's axis:
|
|
264
|
+
* - `'resp'` (default) — ioredis over TCP. Works for Redis, Valkey,
|
|
265
|
+
* KeyDB, Dragonfly, and Upstash's TCP endpoint.
|
|
266
|
+
* - `'http'` — `@upstash/redis` over REST, for serverless/edge where
|
|
267
|
+
* TCP isn't available.
|
|
268
|
+
* Resolved from `CACHE_REDIS_DRIVER` env when omitted. `'redis'` as a
|
|
269
|
+
* store value is the umbrella for the whole RESP family — the server
|
|
270
|
+
* brand is just the url; only the DRIVER is a real choice.
|
|
271
|
+
*/
|
|
272
|
+
readonly driver?: 'resp' | 'http';
|
|
273
|
+
/** Auth token — `http` (Upstash REST) driver only. Falls back to
|
|
274
|
+
* `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` env. */
|
|
275
|
+
readonly token?: string;
|
|
276
|
+
/** Bring your own already-connected RESP client (from
|
|
277
|
+
* `@voltro/kv/connection`'s `connect`, or the shared registry). Takes
|
|
278
|
+
* precedence over `url` / `driver`. */
|
|
279
|
+
readonly client?: RespClient;
|
|
280
|
+
/** Key namespace. Default `'voltro:rl:'`. */
|
|
281
|
+
readonly keyPrefix?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The fully-resolved limit the store consumes against. All dynamic /
|
|
285
|
+
* per-tenant resolution has already collapsed into these scalars. */
|
|
286
|
+
export declare interface ResolvedLimit {
|
|
287
|
+
readonly limit: number;
|
|
288
|
+
readonly windowMs: number;
|
|
289
|
+
readonly algorithm: Algorithm;
|
|
290
|
+
/** Token-bucket capacity. Equals `limit` unless an explicit burst was set. */
|
|
291
|
+
readonly burst: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The limit shape the `resolve` hook returns / receives — scalars only,
|
|
295
|
+
* no dynamic wrappers (resolution already happened). */
|
|
296
|
+
export declare interface ResolvedRuleOverride {
|
|
297
|
+
readonly limit: number;
|
|
298
|
+
readonly window: WindowSpec;
|
|
299
|
+
readonly algorithm?: Algorithm;
|
|
300
|
+
readonly burst?: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Per-tenant override applied on top of a rule. Any field omitted
|
|
304
|
+
* inherits the rule's value. `disabled` exempts the tenant entirely. */
|
|
305
|
+
export declare interface TenantOverride {
|
|
306
|
+
readonly limit?: Dynamic<number>;
|
|
307
|
+
readonly window?: Dynamic<WindowSpec>;
|
|
308
|
+
readonly algorithm?: Algorithm;
|
|
309
|
+
readonly burst?: Dynamic<number>;
|
|
310
|
+
readonly disabled?: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { VoltroPlugin }
|
|
314
|
+
|
|
315
|
+
/** A window length. Either milliseconds as a number, or a unit string
|
|
316
|
+
* like `'1m'`, `'10s'`, `'500ms'`, `'2h'`, `'1d'`. */
|
|
317
|
+
export declare type WindowSpec = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` | number;
|
|
318
|
+
|
|
319
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { RateLimited as e } from "./errors.js";
|
|
2
|
+
import { n as t, t as n } from "./window-lyQ6LXhj.js";
|
|
3
|
+
import { redisStore as r } from "./redisStore.js";
|
|
4
|
+
import { Effect as i } from "effect";
|
|
5
|
+
import { definePlugin as a } from "@voltro/protocol";
|
|
6
|
+
//#region src/store.ts
|
|
7
|
+
var o = (e = {}) => {
|
|
8
|
+
let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, a = /* @__PURE__ */ new Map(), o = (e) => {
|
|
9
|
+
if (a.size <= t) return;
|
|
10
|
+
let n = a.size - t, r = 0;
|
|
11
|
+
for (let t of a.keys()) {
|
|
12
|
+
if (r >= n) break;
|
|
13
|
+
t !== e && (a.delete(t), r += 1);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
consume: (e, t, r) => i.sync(() => {
|
|
18
|
+
let { state: i, decision: s } = n(a.get(e)?.state, t, r);
|
|
19
|
+
return a.set(e, {
|
|
20
|
+
state: i,
|
|
21
|
+
touched: r
|
|
22
|
+
}), o(e), s;
|
|
23
|
+
}),
|
|
24
|
+
sweep: (e) => {
|
|
25
|
+
let t = e - r;
|
|
26
|
+
for (let [e, n] of a) n.touched < t && a.delete(e);
|
|
27
|
+
},
|
|
28
|
+
size: () => a.size
|
|
29
|
+
};
|
|
30
|
+
}, s = (e, t) => ({
|
|
31
|
+
allowed: !0,
|
|
32
|
+
remaining: e.limit,
|
|
33
|
+
resetAtMs: t + e.windowMs,
|
|
34
|
+
retryAfterMs: 0
|
|
35
|
+
}), c = (e = {}) => {
|
|
36
|
+
let t = null;
|
|
37
|
+
return {
|
|
38
|
+
consume: (e, n, r) => t ? t.consume(e, n, r) : i.succeed(s(n, r)),
|
|
39
|
+
sweep: (e) => t?.sweep(e),
|
|
40
|
+
size: () => t ? t.size() : -1,
|
|
41
|
+
bindSql: async (n) => {
|
|
42
|
+
let { postgresStore: r } = await import("./postgresStore.js"), i = r(e);
|
|
43
|
+
await i.bindSql(n), t = i;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}, l = (e, t) => typeof e == "function" ? e(t) : e, u = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : e.test(t), d = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, f = (e, t) => {
|
|
47
|
+
let n = t === null ? void 0 : e.tenants?.[t];
|
|
48
|
+
return {
|
|
49
|
+
limit: n?.limit ?? e.limit,
|
|
50
|
+
window: n?.window ?? e.window,
|
|
51
|
+
algorithm: n?.algorithm ?? e.algorithm,
|
|
52
|
+
burst: n?.burst ?? e.burst,
|
|
53
|
+
by: e.by ?? "subject",
|
|
54
|
+
scope: e.scope ?? "tag",
|
|
55
|
+
disabled: n?.disabled ?? !1
|
|
56
|
+
};
|
|
57
|
+
}, p = (e, t, n) => {
|
|
58
|
+
for (let t = 0; t < e.length; t++) {
|
|
59
|
+
let r = e[t];
|
|
60
|
+
if (u(r.match, n.tag) && d(r.kind, n.kind)) return {
|
|
61
|
+
effective: f(r, n.tenantId),
|
|
62
|
+
ruleId: `r${t}`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (t && d(t.kind, n.kind)) return {
|
|
66
|
+
effective: f(t, n.tenantId),
|
|
67
|
+
ruleId: "default"
|
|
68
|
+
};
|
|
69
|
+
}, m = (e, n) => {
|
|
70
|
+
let r = l(e.limit, n);
|
|
71
|
+
return {
|
|
72
|
+
limit: r,
|
|
73
|
+
windowMs: t(l(e.window, n)),
|
|
74
|
+
algorithm: e.algorithm ?? "sliding-window",
|
|
75
|
+
burst: e.burst === void 0 ? r : l(e.burst, n)
|
|
76
|
+
};
|
|
77
|
+
}, h = (e) => {
|
|
78
|
+
let n = e.limit;
|
|
79
|
+
return {
|
|
80
|
+
limit: n,
|
|
81
|
+
windowMs: t(e.window),
|
|
82
|
+
algorithm: e.algorithm ?? "sliding-window",
|
|
83
|
+
burst: e.burst ?? n
|
|
84
|
+
};
|
|
85
|
+
}, g = (e) => ({
|
|
86
|
+
limit: e.limit,
|
|
87
|
+
window: e.windowMs,
|
|
88
|
+
algorithm: e.algorithm,
|
|
89
|
+
burst: e.burst
|
|
90
|
+
}), _ = (e, t) => {
|
|
91
|
+
if (typeof e == "function") return e(t);
|
|
92
|
+
switch (e) {
|
|
93
|
+
case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
94
|
+
case "tenant": return `t:${t.tenantId ?? "∅"}`;
|
|
95
|
+
case "apiKey": return t.subject.type === "apiKey" ? `k:${t.subject.id}` : `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
96
|
+
case "global": return "g";
|
|
97
|
+
}
|
|
98
|
+
}, v = (e, t) => {
|
|
99
|
+
let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
|
|
100
|
+
return `${i}|${Array.isArray(a) ? a.map((e) => _(e, t)).join("|") : _(a, t)}`;
|
|
101
|
+
}, y = (e) => ({
|
|
102
|
+
tag: e.tag,
|
|
103
|
+
kind: e.kind,
|
|
104
|
+
subject: e.subject,
|
|
105
|
+
tenantId: e.subject.tenantId ?? null,
|
|
106
|
+
traceId: e.traceId
|
|
107
|
+
}), b = (n = {}) => {
|
|
108
|
+
let s = n.rules ?? [], l = n.store === void 0 || n.store === "memory" ? o() : n.store === "postgres" ? c() : n.store === "redis" ? r() : n.store, u = (e) => {
|
|
109
|
+
let t = n.enabled;
|
|
110
|
+
return t === void 0 ? !0 : typeof t == "function" ? t(e) : t;
|
|
111
|
+
}, d = (e, t, r, i) => {
|
|
112
|
+
if (!n.onLimited) return;
|
|
113
|
+
let a = {
|
|
114
|
+
tag: e.tag,
|
|
115
|
+
kind: e.kind,
|
|
116
|
+
key: t,
|
|
117
|
+
tenantId: e.tenantId,
|
|
118
|
+
limit: r.limit,
|
|
119
|
+
windowMs: r.windowMs,
|
|
120
|
+
retryAfterMs: i.retryAfterMs
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
n.onLimited(a);
|
|
124
|
+
} catch {}
|
|
125
|
+
}, f = (e, t) => {
|
|
126
|
+
let r = n.resolve;
|
|
127
|
+
return r ? i.suspend(() => {
|
|
128
|
+
let n = r(e, t);
|
|
129
|
+
return (i.isEffect(n) ? n : n instanceof Promise ? i.promise(() => n) : i.succeed(n)).pipe(i.map((e) => e === "exempt" ? "exempt" : h(e === void 0 ? t : e)));
|
|
130
|
+
}) : i.succeed(h(t));
|
|
131
|
+
}, _ = (t, r) => i.suspend(() => {
|
|
132
|
+
let a = y(r);
|
|
133
|
+
if (!u(a)) return t;
|
|
134
|
+
let o = p(s, n.default, a);
|
|
135
|
+
if (!o || o.effective.disabled) return t;
|
|
136
|
+
let c = g(m(o.effective, a));
|
|
137
|
+
return f(a, c).pipe(i.flatMap((n) => {
|
|
138
|
+
if (n === "exempt") return t;
|
|
139
|
+
let r = v(o, a);
|
|
140
|
+
return l.consume(r, n, Date.now()).pipe(i.catchAllDefect((e) => i.as(i.logWarning(`[ratelimit] store error — failing open for ${a.tag}: ${String(e)}`), {
|
|
141
|
+
allowed: !0,
|
|
142
|
+
remaining: n.limit,
|
|
143
|
+
resetAtMs: Date.now(),
|
|
144
|
+
retryAfterMs: 0
|
|
145
|
+
})), i.flatMap((o) => o.allowed ? t : i.zipRight(i.sync(() => d(a, r, n, o)), i.fail(new e({
|
|
146
|
+
tag: a.tag,
|
|
147
|
+
limit: n.limit,
|
|
148
|
+
windowMs: n.windowMs,
|
|
149
|
+
retryAfterMs: o.retryAfterMs,
|
|
150
|
+
resetAtMs: o.resetAtMs
|
|
151
|
+
})))));
|
|
152
|
+
}));
|
|
153
|
+
}), b = n.http ? async (e, r) => {
|
|
154
|
+
let a = n.http, o = {
|
|
155
|
+
limit: a.limit,
|
|
156
|
+
windowMs: t(a.window),
|
|
157
|
+
algorithm: a.algorithm ?? "fixed-window",
|
|
158
|
+
burst: a.limit
|
|
159
|
+
}, s = r.remoteAddr ?? "unknown", c = Date.now(), u = await i.runPromise(l.consume(`http:ip:${s}`, o, c)), d = String(Math.max(0, Math.ceil((u.resetAtMs - c) / 1e3)));
|
|
160
|
+
return u.allowed ? e() : {
|
|
161
|
+
status: 429,
|
|
162
|
+
body: "Too Many Requests",
|
|
163
|
+
headers: {
|
|
164
|
+
"content-type": "text/plain; charset=utf-8",
|
|
165
|
+
"retry-after": String(Math.max(1, Math.ceil(u.retryAfterMs / 1e3))),
|
|
166
|
+
"ratelimit-limit": String(o.limit),
|
|
167
|
+
"ratelimit-remaining": String(Math.max(0, u.remaining)),
|
|
168
|
+
"ratelimit-reset": d
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
} : void 0, x = [
|
|
172
|
+
"rpc:intercept:mutation",
|
|
173
|
+
"rpc:intercept:query",
|
|
174
|
+
"rpc:intercept:action",
|
|
175
|
+
...n.http ? ["http:intercept"] : []
|
|
176
|
+
], S, C;
|
|
177
|
+
return a({
|
|
178
|
+
name: n.name ? `@voltro/plugin-ratelimit#${n.name}` : "@voltro/plugin-ratelimit",
|
|
179
|
+
description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
|
|
180
|
+
permissions: x,
|
|
181
|
+
declaredEnv: [
|
|
182
|
+
{
|
|
183
|
+
name: "CACHE_REDIS_URL",
|
|
184
|
+
required: !1,
|
|
185
|
+
secret: !0,
|
|
186
|
+
description: "Redis connection URL for the redis store (credential-carrying). Falls back to REDIS_URL."
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "REDIS_URL",
|
|
190
|
+
required: !1,
|
|
191
|
+
secret: !0,
|
|
192
|
+
description: "Fallback Redis connection URL for the redis store (credential-carrying)."
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "CACHE_REDIS_DRIVER",
|
|
196
|
+
required: !1,
|
|
197
|
+
secret: !1,
|
|
198
|
+
description: "Redis driver selector: \"http\" for the Upstash REST client, otherwise RESP."
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "CACHE_REDIS_TOKEN",
|
|
202
|
+
required: !1,
|
|
203
|
+
secret: !0,
|
|
204
|
+
description: "Upstash REST token for the http redis driver. Falls back to UPSTASH_REDIS_REST_TOKEN."
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "UPSTASH_REDIS_REST_TOKEN",
|
|
208
|
+
required: !1,
|
|
209
|
+
secret: !0,
|
|
210
|
+
description: "Fallback Upstash REST token for the http redis driver."
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
...b ? { onHttpRequest: b } : {},
|
|
214
|
+
errorSchemas: [{
|
|
215
|
+
schema: e,
|
|
216
|
+
import: {
|
|
217
|
+
module: "@voltro/plugin-ratelimit/errors",
|
|
218
|
+
name: "RateLimited"
|
|
219
|
+
}
|
|
220
|
+
}],
|
|
221
|
+
interceptMutation: _,
|
|
222
|
+
interceptQuery: _,
|
|
223
|
+
interceptAction: _,
|
|
224
|
+
bindDataStore: (e, t) => {
|
|
225
|
+
let n = t?.sql;
|
|
226
|
+
n && l.bindSql && l.bindSql(n).catch((e) => {
|
|
227
|
+
C = e;
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
onActivate: (e) => i.gen(function* () {
|
|
231
|
+
l.activate && (yield* i.promise(() => l.activate(e.env))), S = setInterval(() => l.sweep(Date.now()), 6e4), S.unref?.(), C !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: C instanceof Error ? C.message : String(C) }), e.logger.info("rate-limit active", {
|
|
232
|
+
rules: s.length,
|
|
233
|
+
hasDefault: n.default !== void 0,
|
|
234
|
+
store: typeof n.store == "string" ? n.store : n.store === void 0 ? "memory" : "custom"
|
|
235
|
+
});
|
|
236
|
+
}),
|
|
237
|
+
onDeactivate: () => i.gen(function* () {
|
|
238
|
+
S && clearInterval(S), l.deactivate && (yield* i.promise(() => l.deactivate()));
|
|
239
|
+
})
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
//#endregion
|
|
243
|
+
export { e as RateLimited, n as consumeBucket, o as memoryStore, t as parseWindow, b as rateLimitPlugin, r as redisStore };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { SqlClient } from '@effect/sql';
|
|
3
|
+
|
|
4
|
+
declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
5
|
+
|
|
6
|
+
export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
|
|
7
|
+
|
|
8
|
+
export declare interface PostgresStoreOptions {
|
|
9
|
+
/** Table name. Default `_voltro_ratelimit`. */
|
|
10
|
+
readonly table?: string;
|
|
11
|
+
/** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
|
|
12
|
+
readonly staleAfterMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare interface RateLimitDecision {
|
|
16
|
+
readonly allowed: boolean;
|
|
17
|
+
/** Remaining allowance in the current window (best-effort estimate). */
|
|
18
|
+
readonly remaining: number;
|
|
19
|
+
/** Epoch-ms when the bucket is expected to be fully replenished. */
|
|
20
|
+
readonly resetAtMs: number;
|
|
21
|
+
/** How long the caller should wait before retrying. 0 when allowed. */
|
|
22
|
+
readonly retryAfterMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare interface RateLimitStore {
|
|
26
|
+
/** Atomically consume one unit for `key` against `limit` at `nowMs`. */
|
|
27
|
+
readonly consume: (key: string, limit: ResolvedLimit, nowMs: number) => Effect.Effect<RateLimitDecision>;
|
|
28
|
+
/** Drop entries untouched long enough to be irrelevant. Called on a timer. */
|
|
29
|
+
readonly sweep: (nowMs: number) => void;
|
|
30
|
+
/** Current number of tracked keys (diagnostics / tests). `-1` when the
|
|
31
|
+
* store can't answer synchronously (e.g. postgres). */
|
|
32
|
+
readonly size: () => number;
|
|
33
|
+
/** Optional one-shot setup at plugin activate (open pools, create
|
|
34
|
+
* tables). Receives the process env. */
|
|
35
|
+
readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
|
|
36
|
+
/** Optional teardown at plugin deactivate (close pools). */
|
|
37
|
+
readonly deactivate?: () => Promise<void>;
|
|
38
|
+
/** Optional bind of the framework's already-open `SqlClient` (from the
|
|
39
|
+
* plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
|
|
40
|
+
* runs through THIS client instead of standing up its own pool; creates
|
|
41
|
+
* its bookkeeping table here. Not present on memory / redis stores. */
|
|
42
|
+
readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The fully-resolved limit the store consumes against. All dynamic /
|
|
46
|
+
* per-tenant resolution has already collapsed into these scalars. */
|
|
47
|
+
declare interface ResolvedLimit {
|
|
48
|
+
readonly limit: number;
|
|
49
|
+
readonly windowMs: number;
|
|
50
|
+
readonly algorithm: Algorithm;
|
|
51
|
+
/** Token-bucket capacity. Equals `limit` unless an explicit burst was set. */
|
|
52
|
+
readonly burst: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { }
|