@lunora/ratelimit 1.0.0-alpha.8 → 1.0.0-alpha.9
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.mts +267 -0
- package/dist/index.d.ts +267 -0
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,60 +1,158 @@
|
|
|
1
1
|
import { Middleware, Plugin } from '@lunora/server';
|
|
2
2
|
import { Id } from '@lunora/values';
|
|
3
3
|
import { LunoraError } from '@lunora/errors';
|
|
4
|
+
/** Rate-limit algorithm. */
|
|
4
5
|
type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
|
|
6
|
+
/** Why a request was denied. */
|
|
5
7
|
type RateLimitReason = "deny" | "rate";
|
|
8
|
+
/** Definition of a single named rate limit. */
|
|
6
9
|
interface RateLimitConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Maximum tokens that can accumulate (the rollover ceiling). Defaults to
|
|
12
|
+
* `rate`: for a token bucket that caps a burst at one period's worth of
|
|
13
|
+
* tokens, and for a fixed window it disables cross-window rollover. Ignored
|
|
14
|
+
* by sliding windows, which always cap at `rate` per `period`.
|
|
15
|
+
*/
|
|
7
16
|
capacity?: number;
|
|
8
17
|
kind: RateLimitKind;
|
|
18
|
+
/** Window/refill period in milliseconds. */
|
|
9
19
|
period: number;
|
|
20
|
+
/** Tokens granted per `period`. */
|
|
10
21
|
rate: number;
|
|
22
|
+
/**
|
|
23
|
+
* Split a hot limit across N independent sub-buckets to avoid a single
|
|
24
|
+
* contended key/Durable Object. Each shard enforces `rate / shards` (and
|
|
25
|
+
* `capacity / shards`); a request is routed to a shard via a deterministic
|
|
26
|
+
* hash of `(name, key)`, so the same key always lands on the same shard and
|
|
27
|
+
* a single key's effective throughput is exactly `rate / shards`. Aggregate
|
|
28
|
+
* throughput across many distinct keys approaches `rate` as keys spread
|
|
29
|
+
* uniformly across shards. Reserve it for high-volume limits where
|
|
30
|
+
* contention bites; leave unset (one bucket) otherwise. Must be a positive
|
|
31
|
+
* integer — `1` is equivalent to unset.
|
|
32
|
+
*/
|
|
11
33
|
shards?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Phase offset in epoch milliseconds for windowed algorithms — windows
|
|
36
|
+
* align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
|
|
37
|
+
*/
|
|
12
38
|
start?: number;
|
|
13
39
|
}
|
|
40
|
+
/** A map of limit name to its config, used to construct a `RateLimiter`. */
|
|
14
41
|
type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
|
|
42
|
+
/** Persisted accounting state for one `(name, key)` pair. */
|
|
15
43
|
interface RateLimitValue {
|
|
44
|
+
/**
|
|
45
|
+
* Sliding window only: request count from the previous window, used to
|
|
46
|
+
* weight the current estimate. Unset for token-bucket / fixed-window.
|
|
47
|
+
*/
|
|
16
48
|
prev?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Token-bucket: timestamp of the last refill. Fixed/sliding window: start of
|
|
51
|
+
* the window the value belongs to.
|
|
52
|
+
*/
|
|
17
53
|
ts: number;
|
|
54
|
+
/**
|
|
55
|
+
* Tokens available (token bucket), tokens remaining in the window (fixed
|
|
56
|
+
* window), or requests made in the current window (sliding window).
|
|
57
|
+
* Fractional for token buckets; negative when reserved ahead.
|
|
58
|
+
*/
|
|
18
59
|
value: number;
|
|
19
60
|
}
|
|
61
|
+
/** Outcome of a `RateLimiter.limit` / `RateLimiter.check` call. */
|
|
20
62
|
interface RateLimitStatus {
|
|
63
|
+
/** Whether the request is permitted. */
|
|
21
64
|
ok: boolean;
|
|
65
|
+
/** Why the request was denied. Absent when `ok`. */
|
|
22
66
|
reason?: RateLimitReason;
|
|
67
|
+
/** Milliseconds until the request would succeed. `0` when `ok` without reservation. */
|
|
23
68
|
retryAfter: number;
|
|
24
69
|
}
|
|
70
|
+
/** Per-call options for `RateLimiter.limit`. */
|
|
25
71
|
interface RateLimitArgs {
|
|
72
|
+
/** Units to consume. Defaults to `1`. */
|
|
26
73
|
count?: number;
|
|
74
|
+
/** Sub-key isolating the limit (per user/team/IP). Omit for a global limit. */
|
|
27
75
|
key?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Permit the request even when capacity is insufficient, reserving future
|
|
78
|
+
* capacity (the stored value goes negative). `retryAfter` then reports when
|
|
79
|
+
* the debt clears. Rejected only when `count` exceeds the bucket capacity.
|
|
80
|
+
*/
|
|
28
81
|
reserve?: boolean;
|
|
82
|
+
/** Throw `RateLimitError` instead of returning a failing status. */
|
|
29
83
|
throws?: boolean;
|
|
30
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Pluggable persistence. Reads and writes are keyed by an opaque storage key
|
|
87
|
+
* the limiter derives from the limit name and `key`. Implementations may be
|
|
88
|
+
* synchronous (in-memory) or asynchronous (SQLite/KV); the limiter awaits
|
|
89
|
+
* either.
|
|
90
|
+
*/
|
|
31
91
|
interface RateLimitStore {
|
|
32
92
|
delete: (storageKey: string) => Promise<void> | void;
|
|
33
93
|
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
34
94
|
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
35
95
|
}
|
|
96
|
+
/** Inputs to {@link evaluate}. */
|
|
36
97
|
interface EvaluateOptions {
|
|
98
|
+
/** When `false`, compute status without consuming (a `check`). */
|
|
37
99
|
consume: boolean;
|
|
100
|
+
/** Units requested. */
|
|
38
101
|
count: number;
|
|
102
|
+
/** Current time in epoch milliseconds. */
|
|
39
103
|
now: number;
|
|
104
|
+
/** Permit a deficit by reserving future capacity (token bucket / within-window). */
|
|
40
105
|
reserve: boolean;
|
|
41
106
|
}
|
|
107
|
+
/** Result of evaluating a limit against its prior state. */
|
|
42
108
|
interface EvaluateResult {
|
|
43
109
|
status: RateLimitStatus;
|
|
110
|
+
/** Next value to persist, or `undefined` when the call must not mutate state. */
|
|
44
111
|
value: RateLimitValue | undefined;
|
|
45
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Project a limit's stored state forward to `now` without consuming: how many
|
|
115
|
+
* units could be admitted right now. Token bucket → the refilled token count;
|
|
116
|
+
* fixed window → tokens left in the current (possibly rolled-over) window;
|
|
117
|
+
* sliding window → `rate` minus the weighted estimate, floored at zero. Pure,
|
|
118
|
+
* like {@link evaluate}, and shares its per-algorithm projection helpers so the
|
|
119
|
+
* two never diverge. Backs `RateLimiter.getValue` so it reports a live figure
|
|
120
|
+
* rather than the last value that happened to be persisted.
|
|
121
|
+
*/
|
|
46
122
|
declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
|
|
47
123
|
ts: number;
|
|
48
124
|
value: number;
|
|
49
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* Evaluate a request against a limit's prior state. Pure: it never reads a
|
|
128
|
+
* clock or persists — the caller supplies `now` and writes back `value` when
|
|
129
|
+
* it is not `undefined`.
|
|
130
|
+
*/
|
|
50
131
|
declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
|
|
51
132
|
interface RateLimiterOptions<Names extends string> {
|
|
52
133
|
config: RateLimitConfigMap<Names>;
|
|
134
|
+
/** Keys that are always denied, regardless of limit state. */
|
|
53
135
|
denyList?: Iterable<string>;
|
|
136
|
+
/**
|
|
137
|
+
* Optional key normalizer applied to every incoming `args.key` (the
|
|
138
|
+
* deny-list check, the storage key, and downstream shard selection all see
|
|
139
|
+
* the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
140
|
+
* IPs/emails so equivalent inputs share a single bucket. The deny-list
|
|
141
|
+
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
|
+
* match.
|
|
143
|
+
*/
|
|
54
144
|
normalize?: (key: string) => string;
|
|
145
|
+
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
55
146
|
now?: () => number;
|
|
147
|
+
/** Persistence. Defaults to a per-instance in-memory store. */
|
|
56
148
|
store?: RateLimitStore;
|
|
57
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Enforces named rate limits over a pluggable store. Construct one per app with
|
|
152
|
+
* a config map; call {@link RateLimiter.limit} to consume and
|
|
153
|
+
* {@link RateLimiter.check} to peek. Framework-agnostic — the `@lunora/ratelimit`
|
|
154
|
+
* middleware wraps it for procedures.
|
|
155
|
+
*/
|
|
58
156
|
declare class RateLimiter<Names extends string = string> {
|
|
59
157
|
private readonly config;
|
|
60
158
|
private readonly denyList;
|
|
@@ -62,7 +160,16 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
62
160
|
private readonly now;
|
|
63
161
|
private readonly store;
|
|
64
162
|
constructor(options: RateLimiterOptions<Names>);
|
|
163
|
+
/** Peek at whether a request would be permitted without consuming. */
|
|
65
164
|
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
165
|
+
/**
|
|
166
|
+
* Read the current config and the units admittable right now for a
|
|
167
|
+
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
|
+
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
169
|
+
* the last persisted figure. For a sharded limit it reads only the single
|
|
170
|
+
* shard `limit()`/`run()` would route this key to — the sibling shards are
|
|
171
|
+
* never touched by this key, so summing them would over-report.
|
|
172
|
+
*/
|
|
66
173
|
getValue(name: Names, args?: {
|
|
67
174
|
key?: string;
|
|
68
175
|
}): Promise<{
|
|
@@ -70,22 +177,65 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
70
177
|
ts: number;
|
|
71
178
|
value: number;
|
|
72
179
|
}>;
|
|
180
|
+
/** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
|
|
73
181
|
limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
|
|
182
|
+
/** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
|
|
74
183
|
reset(name: Names, args?: {
|
|
75
184
|
key?: string;
|
|
76
185
|
}): Promise<void>;
|
|
77
186
|
private resolve;
|
|
78
187
|
private run;
|
|
79
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
|
|
191
|
+
* — the latter lets a procedure bind a durable, ORM-backed limiter at call time
|
|
192
|
+
* (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
|
|
193
|
+
*/
|
|
80
194
|
type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
|
|
81
195
|
interface RateLimitMiddlewareOptions<Context> {
|
|
196
|
+
/** Units to consume per call. Defaults to `1`. */
|
|
82
197
|
count?: number;
|
|
198
|
+
/**
|
|
199
|
+
* Behavior when the limiter itself throws (store unavailable, etc).
|
|
200
|
+
* Defaults to `false` (fail closed: deny the request with a 503). Set to
|
|
201
|
+
* `true` only when degraded availability is preferable to denying traffic
|
|
202
|
+
* — note that a failing limiter then permits every request through.
|
|
203
|
+
*/
|
|
83
204
|
failOpen?: boolean;
|
|
205
|
+
/** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
|
|
84
206
|
key?: (context: Context) => string | undefined;
|
|
207
|
+
/** Override the error message thrown on rejection. */
|
|
85
208
|
message?: string;
|
|
86
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
|
+
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
|
+
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
+
* hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
|
|
215
|
+
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
|
+
*
|
|
217
|
+
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
218
|
+
* availability reason (e.g. the persistence store is unavailable), the
|
|
219
|
+
* middleware **fails closed by default**: it logs via `console.error` and
|
|
220
|
+
* rejects the request with `503`. This is the safer default for
|
|
221
|
+
* security-sensitive limits (auth, account creation). Pass `failOpen: true` to
|
|
222
|
+
* swallow the error and admit the request instead — appropriate only when
|
|
223
|
+
* degraded availability is preferable to refusal. Deterministic caller misuse
|
|
224
|
+
* (an unconfigured limit name, a non-positive count, or a count that exceeds
|
|
225
|
+
* capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
|
|
226
|
+
* **both** policies — a config bug is never masked as a 503 or silently admitted.
|
|
227
|
+
*/
|
|
87
228
|
declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
|
|
229
|
+
/**
|
|
230
|
+
* In-memory store. State lives for the lifetime of the process (or, inside a
|
|
231
|
+
* Durable Object, the instance) — adequate for single-DO limits but not shared
|
|
232
|
+
* across instances. Use {@link createSqlStore} for durable per-DO state.
|
|
233
|
+
*/
|
|
88
234
|
declare const createMemoryStore: () => RateLimitStore;
|
|
235
|
+
/**
|
|
236
|
+
* Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
|
|
237
|
+
* satisfied by `node:sqlite`). Only the `exec` overload is required.
|
|
238
|
+
*/
|
|
89
239
|
interface SqlLike {
|
|
90
240
|
exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
|
|
91
241
|
toArray: () => Row[];
|
|
@@ -93,9 +243,29 @@ interface SqlLike {
|
|
|
93
243
|
}
|
|
94
244
|
interface SqlStoreOptions {
|
|
95
245
|
sql: SqlLike;
|
|
246
|
+
/** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
|
|
96
247
|
table?: string;
|
|
97
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* SQLite-backed store for durable, per-DO rate-limit state. Persists each
|
|
251
|
+
* `(name, key)` pair as one row so limits survive hibernation and eviction.
|
|
252
|
+
*
|
|
253
|
+
* **Atomicity:** the store does not wrap individual operations in an explicit
|
|
254
|
+
* SQL transaction. Inside a Durable Object the DO's input gate serializes
|
|
255
|
+
* every RPC call against the storage, so the limiter's read-modify-write
|
|
256
|
+
* sequence runs to completion without interleaving — this is the same
|
|
257
|
+
* guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
|
|
258
|
+
* (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
|
|
259
|
+
* tests or a custom host) the caller is responsible for serialization; the
|
|
260
|
+
* SQL surface used here (`exec`) is not a substitute for transactional
|
|
261
|
+
* isolation across concurrent invocations.
|
|
262
|
+
*/
|
|
98
263
|
declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
|
|
264
|
+
/**
|
|
265
|
+
* The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
|
|
266
|
+
* `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
|
|
267
|
+
* assignable; only `eq` is exercised.
|
|
268
|
+
*/
|
|
99
269
|
interface RateLimitDatabaseIndexRange {
|
|
100
270
|
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
101
271
|
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
@@ -103,10 +273,17 @@ interface RateLimitDatabaseIndexRange {
|
|
|
103
273
|
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
104
274
|
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
105
275
|
}
|
|
276
|
+
/** The slice of a `ctx.db` table query the store relies on. */
|
|
106
277
|
interface RateLimitDatabaseQuery {
|
|
107
278
|
first: () => Promise<Record<string, unknown> | null>;
|
|
108
279
|
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
109
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
283
|
+
* needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
|
|
284
|
+
* directly — declared here (rather than imported) to keep `@lunora/ratelimit`
|
|
285
|
+
* free of a runtime dependency on `@lunora/server`.
|
|
286
|
+
*/
|
|
110
287
|
interface RateLimitDatabase {
|
|
111
288
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
112
289
|
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
@@ -114,22 +291,82 @@ interface RateLimitDatabase {
|
|
|
114
291
|
query: (table: string) => RateLimitDatabaseQuery;
|
|
115
292
|
}
|
|
116
293
|
interface DatabaseStoreOptions {
|
|
294
|
+
/** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
|
|
117
295
|
db: RateLimitDatabase;
|
|
296
|
+
/** Index that resolves a row by its key column. Defaults to `by_key`. */
|
|
118
297
|
index?: string;
|
|
298
|
+
/** Column storing the opaque key. Defaults to `key`. */
|
|
119
299
|
keyField?: string;
|
|
300
|
+
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
120
301
|
table?: string;
|
|
121
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
|
|
305
|
+
* inside a procedure (the procedure context exposes no raw SQL). Declare a
|
|
306
|
+
* table with the key column and its index, e.g.
|
|
307
|
+
*
|
|
308
|
+
* ```ts
|
|
309
|
+
* rateLimits: defineTable({
|
|
310
|
+
* key: v.string(),
|
|
311
|
+
* ts: v.number(),
|
|
312
|
+
* value: v.number(),
|
|
313
|
+
* prev: v.optional(v.number()),
|
|
314
|
+
* }).index("by_key", ["key"])
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
318
|
+
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
319
|
+
*/
|
|
122
320
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
321
|
+
/**
|
|
322
|
+
* DB-backed rate-limit middleware sugar. Collapses the common
|
|
323
|
+
*
|
|
324
|
+
* ```ts
|
|
325
|
+
* rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
|
|
326
|
+
* ```
|
|
327
|
+
*
|
|
328
|
+
* into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
|
|
329
|
+
* whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
|
|
330
|
+
* on the DO the procedure runs on). Every query/mutation/action ctx exposes a
|
|
331
|
+
* compatible `db`, so it slots straight into a `.use(...)` chain.
|
|
332
|
+
*
|
|
333
|
+
* Pass `options.store` to point at a non-default backing table/index/key column
|
|
334
|
+
* (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
|
|
335
|
+
* `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
|
|
336
|
+
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
337
|
+
* autocompletes to its declared limit names.
|
|
338
|
+
*
|
|
339
|
+
* Re-exported as `dbRateLimit` from the package root.
|
|
340
|
+
*
|
|
341
|
+
* ```ts
|
|
342
|
+
* const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
|
|
343
|
+
*
|
|
344
|
+
* export const send = mutation
|
|
345
|
+
* .input({ text: v.string() })
|
|
346
|
+
* .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
|
|
347
|
+
* .mutation(async ({ ctx, args }) => ...);
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
123
350
|
declare const databaseRateLimit: <Context extends {
|
|
124
351
|
db: RateLimitDatabase;
|
|
125
352
|
}, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
|
|
126
353
|
store?: Omit<DatabaseStoreOptions, "db">;
|
|
127
354
|
}) => Middleware<Context, Context>;
|
|
355
|
+
/**
|
|
356
|
+
* Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
|
|
357
|
+
* `LunoraError` subclass whose code/status track `status.reason`: a rate
|
|
358
|
+
* rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
|
|
359
|
+
* the same mapping the middleware applies, so both entry points surface the
|
|
360
|
+
* identical wire code (a permanent deny is never a retryable 429). The
|
|
361
|
+
* middleware itself throws a bare structural `LunoraError`, so this is for
|
|
362
|
+
* direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
|
|
363
|
+
*/
|
|
128
364
|
declare class RateLimitError extends LunoraError {
|
|
129
365
|
readonly reason: RateLimitReason | undefined;
|
|
130
366
|
readonly retryAfter: number;
|
|
131
367
|
constructor(status: RateLimitStatus, message?: string);
|
|
132
368
|
}
|
|
369
|
+
/** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
|
|
133
370
|
interface RatelimitApiContext<Context> {
|
|
134
371
|
api: (Context extends {
|
|
135
372
|
api: infer A;
|
|
@@ -137,6 +374,36 @@ interface RatelimitApiContext<Context> {
|
|
|
137
374
|
ratelimit: RateLimiter;
|
|
138
375
|
};
|
|
139
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
|
|
379
|
+
* form of the plugin contract: instead of (or alongside) the enforcing
|
|
380
|
+
* `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
|
|
381
|
+
* under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
|
|
382
|
+
* programmatically.
|
|
383
|
+
*
|
|
384
|
+
* Install the middleware with one `.use(...)` (or fold it in with
|
|
385
|
+
* `composePluginMiddleware([...])`):
|
|
386
|
+
*
|
|
387
|
+
* ```ts
|
|
388
|
+
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
389
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
390
|
+
* export const send = c.mutation
|
|
391
|
+
* .use(ratelimitPlugin(limiter).middleware!)
|
|
392
|
+
* .mutation(async ({ ctx, args }) => {
|
|
393
|
+
* const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
|
|
394
|
+
* if (!status.ok) throw new Error("slow down");
|
|
395
|
+
* // …
|
|
396
|
+
* });
|
|
397
|
+
* ```
|
|
398
|
+
*
|
|
399
|
+
* The plugin ships no schema extension — the limiter's persistence is whatever
|
|
400
|
+
* store the resolved {@link RateLimiter} was built with — so it is a
|
|
401
|
+
* middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
|
|
402
|
+
*
|
|
403
|
+
* Built as a plain {@link Plugin} literal (the key is the fixed string
|
|
404
|
+
* `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
|
|
405
|
+
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
406
|
+
*/
|
|
140
407
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
141
408
|
declare const VERSION = "0.0.0";
|
|
142
409
|
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,60 +1,158 @@
|
|
|
1
1
|
import { Middleware, Plugin } from '@lunora/server';
|
|
2
2
|
import { Id } from '@lunora/values';
|
|
3
3
|
import { LunoraError } from '@lunora/errors';
|
|
4
|
+
/** Rate-limit algorithm. */
|
|
4
5
|
type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
|
|
6
|
+
/** Why a request was denied. */
|
|
5
7
|
type RateLimitReason = "deny" | "rate";
|
|
8
|
+
/** Definition of a single named rate limit. */
|
|
6
9
|
interface RateLimitConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Maximum tokens that can accumulate (the rollover ceiling). Defaults to
|
|
12
|
+
* `rate`: for a token bucket that caps a burst at one period's worth of
|
|
13
|
+
* tokens, and for a fixed window it disables cross-window rollover. Ignored
|
|
14
|
+
* by sliding windows, which always cap at `rate` per `period`.
|
|
15
|
+
*/
|
|
7
16
|
capacity?: number;
|
|
8
17
|
kind: RateLimitKind;
|
|
18
|
+
/** Window/refill period in milliseconds. */
|
|
9
19
|
period: number;
|
|
20
|
+
/** Tokens granted per `period`. */
|
|
10
21
|
rate: number;
|
|
22
|
+
/**
|
|
23
|
+
* Split a hot limit across N independent sub-buckets to avoid a single
|
|
24
|
+
* contended key/Durable Object. Each shard enforces `rate / shards` (and
|
|
25
|
+
* `capacity / shards`); a request is routed to a shard via a deterministic
|
|
26
|
+
* hash of `(name, key)`, so the same key always lands on the same shard and
|
|
27
|
+
* a single key's effective throughput is exactly `rate / shards`. Aggregate
|
|
28
|
+
* throughput across many distinct keys approaches `rate` as keys spread
|
|
29
|
+
* uniformly across shards. Reserve it for high-volume limits where
|
|
30
|
+
* contention bites; leave unset (one bucket) otherwise. Must be a positive
|
|
31
|
+
* integer — `1` is equivalent to unset.
|
|
32
|
+
*/
|
|
11
33
|
shards?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Phase offset in epoch milliseconds for windowed algorithms — windows
|
|
36
|
+
* align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
|
|
37
|
+
*/
|
|
12
38
|
start?: number;
|
|
13
39
|
}
|
|
40
|
+
/** A map of limit name to its config, used to construct a `RateLimiter`. */
|
|
14
41
|
type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
|
|
42
|
+
/** Persisted accounting state for one `(name, key)` pair. */
|
|
15
43
|
interface RateLimitValue {
|
|
44
|
+
/**
|
|
45
|
+
* Sliding window only: request count from the previous window, used to
|
|
46
|
+
* weight the current estimate. Unset for token-bucket / fixed-window.
|
|
47
|
+
*/
|
|
16
48
|
prev?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Token-bucket: timestamp of the last refill. Fixed/sliding window: start of
|
|
51
|
+
* the window the value belongs to.
|
|
52
|
+
*/
|
|
17
53
|
ts: number;
|
|
54
|
+
/**
|
|
55
|
+
* Tokens available (token bucket), tokens remaining in the window (fixed
|
|
56
|
+
* window), or requests made in the current window (sliding window).
|
|
57
|
+
* Fractional for token buckets; negative when reserved ahead.
|
|
58
|
+
*/
|
|
18
59
|
value: number;
|
|
19
60
|
}
|
|
61
|
+
/** Outcome of a `RateLimiter.limit` / `RateLimiter.check` call. */
|
|
20
62
|
interface RateLimitStatus {
|
|
63
|
+
/** Whether the request is permitted. */
|
|
21
64
|
ok: boolean;
|
|
65
|
+
/** Why the request was denied. Absent when `ok`. */
|
|
22
66
|
reason?: RateLimitReason;
|
|
67
|
+
/** Milliseconds until the request would succeed. `0` when `ok` without reservation. */
|
|
23
68
|
retryAfter: number;
|
|
24
69
|
}
|
|
70
|
+
/** Per-call options for `RateLimiter.limit`. */
|
|
25
71
|
interface RateLimitArgs {
|
|
72
|
+
/** Units to consume. Defaults to `1`. */
|
|
26
73
|
count?: number;
|
|
74
|
+
/** Sub-key isolating the limit (per user/team/IP). Omit for a global limit. */
|
|
27
75
|
key?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Permit the request even when capacity is insufficient, reserving future
|
|
78
|
+
* capacity (the stored value goes negative). `retryAfter` then reports when
|
|
79
|
+
* the debt clears. Rejected only when `count` exceeds the bucket capacity.
|
|
80
|
+
*/
|
|
28
81
|
reserve?: boolean;
|
|
82
|
+
/** Throw `RateLimitError` instead of returning a failing status. */
|
|
29
83
|
throws?: boolean;
|
|
30
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Pluggable persistence. Reads and writes are keyed by an opaque storage key
|
|
87
|
+
* the limiter derives from the limit name and `key`. Implementations may be
|
|
88
|
+
* synchronous (in-memory) or asynchronous (SQLite/KV); the limiter awaits
|
|
89
|
+
* either.
|
|
90
|
+
*/
|
|
31
91
|
interface RateLimitStore {
|
|
32
92
|
delete: (storageKey: string) => Promise<void> | void;
|
|
33
93
|
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
34
94
|
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
35
95
|
}
|
|
96
|
+
/** Inputs to {@link evaluate}. */
|
|
36
97
|
interface EvaluateOptions {
|
|
98
|
+
/** When `false`, compute status without consuming (a `check`). */
|
|
37
99
|
consume: boolean;
|
|
100
|
+
/** Units requested. */
|
|
38
101
|
count: number;
|
|
102
|
+
/** Current time in epoch milliseconds. */
|
|
39
103
|
now: number;
|
|
104
|
+
/** Permit a deficit by reserving future capacity (token bucket / within-window). */
|
|
40
105
|
reserve: boolean;
|
|
41
106
|
}
|
|
107
|
+
/** Result of evaluating a limit against its prior state. */
|
|
42
108
|
interface EvaluateResult {
|
|
43
109
|
status: RateLimitStatus;
|
|
110
|
+
/** Next value to persist, or `undefined` when the call must not mutate state. */
|
|
44
111
|
value: RateLimitValue | undefined;
|
|
45
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Project a limit's stored state forward to `now` without consuming: how many
|
|
115
|
+
* units could be admitted right now. Token bucket → the refilled token count;
|
|
116
|
+
* fixed window → tokens left in the current (possibly rolled-over) window;
|
|
117
|
+
* sliding window → `rate` minus the weighted estimate, floored at zero. Pure,
|
|
118
|
+
* like {@link evaluate}, and shares its per-algorithm projection helpers so the
|
|
119
|
+
* two never diverge. Backs `RateLimiter.getValue` so it reports a live figure
|
|
120
|
+
* rather than the last value that happened to be persisted.
|
|
121
|
+
*/
|
|
46
122
|
declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
|
|
47
123
|
ts: number;
|
|
48
124
|
value: number;
|
|
49
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* Evaluate a request against a limit's prior state. Pure: it never reads a
|
|
128
|
+
* clock or persists — the caller supplies `now` and writes back `value` when
|
|
129
|
+
* it is not `undefined`.
|
|
130
|
+
*/
|
|
50
131
|
declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
|
|
51
132
|
interface RateLimiterOptions<Names extends string> {
|
|
52
133
|
config: RateLimitConfigMap<Names>;
|
|
134
|
+
/** Keys that are always denied, regardless of limit state. */
|
|
53
135
|
denyList?: Iterable<string>;
|
|
136
|
+
/**
|
|
137
|
+
* Optional key normalizer applied to every incoming `args.key` (the
|
|
138
|
+
* deny-list check, the storage key, and downstream shard selection all see
|
|
139
|
+
* the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
140
|
+
* IPs/emails so equivalent inputs share a single bucket. The deny-list
|
|
141
|
+
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
|
+
* match.
|
|
143
|
+
*/
|
|
54
144
|
normalize?: (key: string) => string;
|
|
145
|
+
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
55
146
|
now?: () => number;
|
|
147
|
+
/** Persistence. Defaults to a per-instance in-memory store. */
|
|
56
148
|
store?: RateLimitStore;
|
|
57
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Enforces named rate limits over a pluggable store. Construct one per app with
|
|
152
|
+
* a config map; call {@link RateLimiter.limit} to consume and
|
|
153
|
+
* {@link RateLimiter.check} to peek. Framework-agnostic — the `@lunora/ratelimit`
|
|
154
|
+
* middleware wraps it for procedures.
|
|
155
|
+
*/
|
|
58
156
|
declare class RateLimiter<Names extends string = string> {
|
|
59
157
|
private readonly config;
|
|
60
158
|
private readonly denyList;
|
|
@@ -62,7 +160,16 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
62
160
|
private readonly now;
|
|
63
161
|
private readonly store;
|
|
64
162
|
constructor(options: RateLimiterOptions<Names>);
|
|
163
|
+
/** Peek at whether a request would be permitted without consuming. */
|
|
65
164
|
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
165
|
+
/**
|
|
166
|
+
* Read the current config and the units admittable right now for a
|
|
167
|
+
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
|
+
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
169
|
+
* the last persisted figure. For a sharded limit it reads only the single
|
|
170
|
+
* shard `limit()`/`run()` would route this key to — the sibling shards are
|
|
171
|
+
* never touched by this key, so summing them would over-report.
|
|
172
|
+
*/
|
|
66
173
|
getValue(name: Names, args?: {
|
|
67
174
|
key?: string;
|
|
68
175
|
}): Promise<{
|
|
@@ -70,22 +177,65 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
70
177
|
ts: number;
|
|
71
178
|
value: number;
|
|
72
179
|
}>;
|
|
180
|
+
/** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
|
|
73
181
|
limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
|
|
182
|
+
/** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
|
|
74
183
|
reset(name: Names, args?: {
|
|
75
184
|
key?: string;
|
|
76
185
|
}): Promise<void>;
|
|
77
186
|
private resolve;
|
|
78
187
|
private run;
|
|
79
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
|
|
191
|
+
* — the latter lets a procedure bind a durable, ORM-backed limiter at call time
|
|
192
|
+
* (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
|
|
193
|
+
*/
|
|
80
194
|
type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
|
|
81
195
|
interface RateLimitMiddlewareOptions<Context> {
|
|
196
|
+
/** Units to consume per call. Defaults to `1`. */
|
|
82
197
|
count?: number;
|
|
198
|
+
/**
|
|
199
|
+
* Behavior when the limiter itself throws (store unavailable, etc).
|
|
200
|
+
* Defaults to `false` (fail closed: deny the request with a 503). Set to
|
|
201
|
+
* `true` only when degraded availability is preferable to denying traffic
|
|
202
|
+
* — note that a failing limiter then permits every request through.
|
|
203
|
+
*/
|
|
83
204
|
failOpen?: boolean;
|
|
205
|
+
/** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
|
|
84
206
|
key?: (context: Context) => string | undefined;
|
|
207
|
+
/** Override the error message thrown on rejection. */
|
|
85
208
|
message?: string;
|
|
86
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
|
+
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
|
+
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
+
* hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
|
|
215
|
+
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
|
+
*
|
|
217
|
+
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
218
|
+
* availability reason (e.g. the persistence store is unavailable), the
|
|
219
|
+
* middleware **fails closed by default**: it logs via `console.error` and
|
|
220
|
+
* rejects the request with `503`. This is the safer default for
|
|
221
|
+
* security-sensitive limits (auth, account creation). Pass `failOpen: true` to
|
|
222
|
+
* swallow the error and admit the request instead — appropriate only when
|
|
223
|
+
* degraded availability is preferable to refusal. Deterministic caller misuse
|
|
224
|
+
* (an unconfigured limit name, a non-positive count, or a count that exceeds
|
|
225
|
+
* capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
|
|
226
|
+
* **both** policies — a config bug is never masked as a 503 or silently admitted.
|
|
227
|
+
*/
|
|
87
228
|
declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
|
|
229
|
+
/**
|
|
230
|
+
* In-memory store. State lives for the lifetime of the process (or, inside a
|
|
231
|
+
* Durable Object, the instance) — adequate for single-DO limits but not shared
|
|
232
|
+
* across instances. Use {@link createSqlStore} for durable per-DO state.
|
|
233
|
+
*/
|
|
88
234
|
declare const createMemoryStore: () => RateLimitStore;
|
|
235
|
+
/**
|
|
236
|
+
* Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
|
|
237
|
+
* satisfied by `node:sqlite`). Only the `exec` overload is required.
|
|
238
|
+
*/
|
|
89
239
|
interface SqlLike {
|
|
90
240
|
exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
|
|
91
241
|
toArray: () => Row[];
|
|
@@ -93,9 +243,29 @@ interface SqlLike {
|
|
|
93
243
|
}
|
|
94
244
|
interface SqlStoreOptions {
|
|
95
245
|
sql: SqlLike;
|
|
246
|
+
/** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
|
|
96
247
|
table?: string;
|
|
97
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* SQLite-backed store for durable, per-DO rate-limit state. Persists each
|
|
251
|
+
* `(name, key)` pair as one row so limits survive hibernation and eviction.
|
|
252
|
+
*
|
|
253
|
+
* **Atomicity:** the store does not wrap individual operations in an explicit
|
|
254
|
+
* SQL transaction. Inside a Durable Object the DO's input gate serializes
|
|
255
|
+
* every RPC call against the storage, so the limiter's read-modify-write
|
|
256
|
+
* sequence runs to completion without interleaving — this is the same
|
|
257
|
+
* guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
|
|
258
|
+
* (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
|
|
259
|
+
* tests or a custom host) the caller is responsible for serialization; the
|
|
260
|
+
* SQL surface used here (`exec`) is not a substitute for transactional
|
|
261
|
+
* isolation across concurrent invocations.
|
|
262
|
+
*/
|
|
98
263
|
declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
|
|
264
|
+
/**
|
|
265
|
+
* The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
|
|
266
|
+
* `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
|
|
267
|
+
* assignable; only `eq` is exercised.
|
|
268
|
+
*/
|
|
99
269
|
interface RateLimitDatabaseIndexRange {
|
|
100
270
|
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
101
271
|
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
@@ -103,10 +273,17 @@ interface RateLimitDatabaseIndexRange {
|
|
|
103
273
|
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
104
274
|
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
105
275
|
}
|
|
276
|
+
/** The slice of a `ctx.db` table query the store relies on. */
|
|
106
277
|
interface RateLimitDatabaseQuery {
|
|
107
278
|
first: () => Promise<Record<string, unknown> | null>;
|
|
108
279
|
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
109
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
283
|
+
* needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
|
|
284
|
+
* directly — declared here (rather than imported) to keep `@lunora/ratelimit`
|
|
285
|
+
* free of a runtime dependency on `@lunora/server`.
|
|
286
|
+
*/
|
|
110
287
|
interface RateLimitDatabase {
|
|
111
288
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
112
289
|
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
@@ -114,22 +291,82 @@ interface RateLimitDatabase {
|
|
|
114
291
|
query: (table: string) => RateLimitDatabaseQuery;
|
|
115
292
|
}
|
|
116
293
|
interface DatabaseStoreOptions {
|
|
294
|
+
/** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
|
|
117
295
|
db: RateLimitDatabase;
|
|
296
|
+
/** Index that resolves a row by its key column. Defaults to `by_key`. */
|
|
118
297
|
index?: string;
|
|
298
|
+
/** Column storing the opaque key. Defaults to `key`. */
|
|
119
299
|
keyField?: string;
|
|
300
|
+
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
120
301
|
table?: string;
|
|
121
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
|
|
305
|
+
* inside a procedure (the procedure context exposes no raw SQL). Declare a
|
|
306
|
+
* table with the key column and its index, e.g.
|
|
307
|
+
*
|
|
308
|
+
* ```ts
|
|
309
|
+
* rateLimits: defineTable({
|
|
310
|
+
* key: v.string(),
|
|
311
|
+
* ts: v.number(),
|
|
312
|
+
* value: v.number(),
|
|
313
|
+
* prev: v.optional(v.number()),
|
|
314
|
+
* }).index("by_key", ["key"])
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
318
|
+
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
319
|
+
*/
|
|
122
320
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
321
|
+
/**
|
|
322
|
+
* DB-backed rate-limit middleware sugar. Collapses the common
|
|
323
|
+
*
|
|
324
|
+
* ```ts
|
|
325
|
+
* rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
|
|
326
|
+
* ```
|
|
327
|
+
*
|
|
328
|
+
* into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
|
|
329
|
+
* whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
|
|
330
|
+
* on the DO the procedure runs on). Every query/mutation/action ctx exposes a
|
|
331
|
+
* compatible `db`, so it slots straight into a `.use(...)` chain.
|
|
332
|
+
*
|
|
333
|
+
* Pass `options.store` to point at a non-default backing table/index/key column
|
|
334
|
+
* (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
|
|
335
|
+
* `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
|
|
336
|
+
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
337
|
+
* autocompletes to its declared limit names.
|
|
338
|
+
*
|
|
339
|
+
* Re-exported as `dbRateLimit` from the package root.
|
|
340
|
+
*
|
|
341
|
+
* ```ts
|
|
342
|
+
* const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
|
|
343
|
+
*
|
|
344
|
+
* export const send = mutation
|
|
345
|
+
* .input({ text: v.string() })
|
|
346
|
+
* .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
|
|
347
|
+
* .mutation(async ({ ctx, args }) => ...);
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
123
350
|
declare const databaseRateLimit: <Context extends {
|
|
124
351
|
db: RateLimitDatabase;
|
|
125
352
|
}, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
|
|
126
353
|
store?: Omit<DatabaseStoreOptions, "db">;
|
|
127
354
|
}) => Middleware<Context, Context>;
|
|
355
|
+
/**
|
|
356
|
+
* Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
|
|
357
|
+
* `LunoraError` subclass whose code/status track `status.reason`: a rate
|
|
358
|
+
* rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
|
|
359
|
+
* the same mapping the middleware applies, so both entry points surface the
|
|
360
|
+
* identical wire code (a permanent deny is never a retryable 429). The
|
|
361
|
+
* middleware itself throws a bare structural `LunoraError`, so this is for
|
|
362
|
+
* direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
|
|
363
|
+
*/
|
|
128
364
|
declare class RateLimitError extends LunoraError {
|
|
129
365
|
readonly reason: RateLimitReason | undefined;
|
|
130
366
|
readonly retryAfter: number;
|
|
131
367
|
constructor(status: RateLimitStatus, message?: string);
|
|
132
368
|
}
|
|
369
|
+
/** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
|
|
133
370
|
interface RatelimitApiContext<Context> {
|
|
134
371
|
api: (Context extends {
|
|
135
372
|
api: infer A;
|
|
@@ -137,6 +374,36 @@ interface RatelimitApiContext<Context> {
|
|
|
137
374
|
ratelimit: RateLimiter;
|
|
138
375
|
};
|
|
139
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
|
|
379
|
+
* form of the plugin contract: instead of (or alongside) the enforcing
|
|
380
|
+
* `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
|
|
381
|
+
* under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
|
|
382
|
+
* programmatically.
|
|
383
|
+
*
|
|
384
|
+
* Install the middleware with one `.use(...)` (or fold it in with
|
|
385
|
+
* `composePluginMiddleware([...])`):
|
|
386
|
+
*
|
|
387
|
+
* ```ts
|
|
388
|
+
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
389
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
390
|
+
* export const send = c.mutation
|
|
391
|
+
* .use(ratelimitPlugin(limiter).middleware!)
|
|
392
|
+
* .mutation(async ({ ctx, args }) => {
|
|
393
|
+
* const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
|
|
394
|
+
* if (!status.ok) throw new Error("slow down");
|
|
395
|
+
* // …
|
|
396
|
+
* });
|
|
397
|
+
* ```
|
|
398
|
+
*
|
|
399
|
+
* The plugin ships no schema extension — the limiter's persistence is whatever
|
|
400
|
+
* store the resolved {@link RateLimiter} was built with — so it is a
|
|
401
|
+
* middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
|
|
402
|
+
*
|
|
403
|
+
* Built as a plain {@link Plugin} literal (the key is the fixed string
|
|
404
|
+
* `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
|
|
405
|
+
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
406
|
+
*/
|
|
140
407
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
141
408
|
declare const VERSION = "0.0.0";
|
|
142
409
|
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/ratelimit",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.9",
|
|
4
4
|
"description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.6"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": "^22.15.0 || >=24.11.0"
|