@lunora/ratelimit 1.0.0-alpha.12 → 1.0.0-alpha.120
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/README.md +49 -18
- package/dist/index.d.mts +117 -27
- package/dist/index.d.ts +117 -27
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/RateLimitError-9Y5OLxpl.mjs +1 -0
- package/dist/packem_shared/RateLimiter-Cbc67DNk.mjs +1 -0
- package/dist/packem_shared/availableAt-BSKYrouo.mjs +1 -0
- package/dist/packem_shared/createDbStore-ve7GsO2i.mjs +1 -0
- package/dist/packem_shared/dbRateLimit-BuvEF1x0.mjs +1 -0
- package/dist/packem_shared/rateLimit-CWZur6XE.mjs +1 -0
- package/dist/packem_shared/ratelimitPlugin-CFnDs9rj.mjs +1 -0
- package/dist/packem_shared/tokenBudget-BRlWn1im.mjs +1 -0
- package/package.json +7 -3
- package/dist/packem_shared/RateLimitError-xJ2Iv-i7.mjs +0 -1
- package/dist/packem_shared/RateLimiter-BcVZuxFA.mjs +0 -1
- package/dist/packem_shared/availableAt-DITZ7jdT.mjs +0 -1
- package/dist/packem_shared/createDbStore-VpnhCmRx.mjs +0 -1
- package/dist/packem_shared/dbRateLimit-D7voUQX3.mjs +0 -1
- package/dist/packem_shared/rateLimit-DLcwK-84.mjs +0 -1
- package/dist/packem_shared/ratelimitPlugin-D1QHZF9b.mjs +0 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
</a>
|
|
8
8
|
|
|
9
|
-
<h3 align="center">Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list,
|
|
9
|
+
<h3 align="center">Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, pluggable stores, and procedure middleware</h3>
|
|
10
10
|
|
|
11
11
|
<!-- END_PACKAGE_OG_IMAGE_PLACEHOLDER -->
|
|
12
12
|
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
Rate limiting for Lunora: token-bucket, fixed-window, and sliding-window algorithms, a deny list,
|
|
37
|
+
Rate limiting for Lunora: token-bucket, fixed-window, and sliding-window algorithms, a deny list, pluggable stores (in-memory, SQL, or the Lunora ORM), and procedure middleware that rides the `.use()` chain.
|
|
38
38
|
|
|
39
39
|
Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
|
|
40
40
|
|
|
@@ -59,37 +59,68 @@ to a procedure's `.use(...)` chain. The procedure builders (`mutation`, …) com
|
|
|
59
59
|
from your app's generated `_generated/server` module, not from a package.
|
|
60
60
|
|
|
61
61
|
```ts
|
|
62
|
-
import {
|
|
62
|
+
import { dbRateLimit } from "@lunora/ratelimit";
|
|
63
63
|
|
|
64
64
|
import { mutation } from "./_generated/server";
|
|
65
65
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const config = {
|
|
67
|
+
login: { kind: "fixed window", period: 60_000, rate: 5 },
|
|
68
|
+
send: { kind: "token bucket", period: 1_000, rate: 10 },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// `dbRateLimit` builds the limiter per call, backed by a Lunora table via
|
|
72
|
+
// `ctx.db`, so the count is durable. A `RateLimiter` built with no explicit
|
|
73
|
+
// `store` falls back to `createMemoryStore` (a per-instance in-memory Map) —
|
|
74
|
+
// fine for a limit that only ever needs to hold within one Durable Object
|
|
75
|
+
// instance, but a limiter like `login` needs a durable store or it stops
|
|
76
|
+
// enforcing the configured rate the moment the DO instance is sharded,
|
|
77
|
+
// replicated, or recreated.
|
|
73
78
|
// As procedure middleware — throws a structural LunoraError (429/403) on rejection.
|
|
74
|
-
|
|
79
|
+
// `key` must resolve to a string — a resolver returning `undefined` throws
|
|
80
|
+
// rather than silently sharing one bucket across every keyless caller.
|
|
81
|
+
export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" })).mutation(async ({ ctx }) => {
|
|
75
82
|
// …
|
|
76
83
|
});
|
|
77
84
|
```
|
|
78
85
|
|
|
79
|
-
Or call the limiter directly
|
|
86
|
+
Or call the limiter directly, inside a mutation/action so `ctx.db` is available.
|
|
87
|
+
A login limiter belongs in an **action**: a mutation's `ctx.db` writes ride its
|
|
88
|
+
storage transaction, so a handler that throws (a wrong password) rolls the
|
|
89
|
+
consumed unit back and every failed attempt is free. An action's writes commit
|
|
90
|
+
on their own, so the charge stays whether or not the handler throws.
|
|
80
91
|
|
|
81
92
|
```ts
|
|
82
|
-
|
|
93
|
+
import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
|
|
94
|
+
|
|
95
|
+
import { action } from "./_generated/server";
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
export const login = action.action(async ({ ctx, args }) => {
|
|
98
|
+
const limiter = new RateLimiter({ config, store: createDbStore({ db: ctx.db }) });
|
|
99
|
+
const status = await limiter.limit("login", { key: args.email });
|
|
100
|
+
|
|
101
|
+
if (!status.ok) {
|
|
102
|
+
// Stop here — `limit` returns a failing status by default rather than
|
|
103
|
+
// throwing, so falling through authenticates the rejected attempt.
|
|
104
|
+
// status.retryAfter is milliseconds until the request would succeed.
|
|
105
|
+
// status.reason is "rate" or "deny".
|
|
106
|
+
throw new RateLimitError(status);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await authenticate(args);
|
|
110
|
+
});
|
|
88
111
|
```
|
|
89
112
|
|
|
90
113
|
For durable per-DO state inside a procedure, back the limiter with a Lunora
|
|
91
114
|
table via `dbRateLimit(config, name, options)`, or supply a `store`
|
|
92
|
-
(`
|
|
115
|
+
(`createSqlStore` / `createDbStore`). `createMemoryStore` stays the default —
|
|
116
|
+
correct inside a single Durable Object — but constructing a `RateLimiter` with
|
|
117
|
+
no explicit `store` now warns once so that choice is visible instead of
|
|
118
|
+
silent.
|
|
119
|
+
|
|
120
|
+
A key that is an IPv6 address (for example `ctx.ip`) is bucketed by its /64
|
|
121
|
+
prefix, and an IPv4-mapped address by the IPv4 address it wraps. An IPv6 client
|
|
122
|
+
usually controls a whole /64, so a per-address bucket would let it pick a fresh
|
|
123
|
+
address for every request. A deny-list IPv6 entry bans its /64.
|
|
93
124
|
|
|
94
125
|
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
|
|
95
126
|
|
package/dist/index.d.mts
CHANGED
|
@@ -19,18 +19,6 @@ interface RateLimitConfig {
|
|
|
19
19
|
period: number;
|
|
20
20
|
/** Tokens granted per `period`. */
|
|
21
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
|
-
*/
|
|
33
|
-
shards?: number;
|
|
34
22
|
/**
|
|
35
23
|
* Phase offset in epoch milliseconds for windowed algorithms — windows
|
|
36
24
|
* align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
|
|
@@ -135,11 +123,15 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
135
123
|
denyList?: Iterable<string>;
|
|
136
124
|
/**
|
|
137
125
|
* Optional key normalizer applied to every incoming `args.key` (the
|
|
138
|
-
* deny-list check
|
|
139
|
-
*
|
|
140
|
-
* IPs/emails so equivalent inputs share a single bucket. The deny-list
|
|
126
|
+
* deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
127
|
+
* emails so equivalent inputs share a single bucket. The deny-list
|
|
141
128
|
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
129
|
* match.
|
|
130
|
+
*
|
|
131
|
+
* After it, a key that is an IPv6 address is always collapsed to its /64
|
|
132
|
+
* (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
|
|
133
|
+
* `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
|
|
134
|
+
* delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
|
|
143
135
|
*/
|
|
144
136
|
normalize?: (key: string) => string;
|
|
145
137
|
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
@@ -166,9 +158,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
166
158
|
* Read the current config and the units admittable right now for a
|
|
167
159
|
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
160
|
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
169
|
-
* the last persisted figure.
|
|
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.
|
|
161
|
+
* the last persisted figure.
|
|
172
162
|
*/
|
|
173
163
|
getValue(name: Names, args?: {
|
|
174
164
|
key?: string;
|
|
@@ -183,6 +173,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
183
173
|
reset(name: Names, args?: {
|
|
184
174
|
key?: string;
|
|
185
175
|
}): Promise<void>;
|
|
176
|
+
private normalizeKey;
|
|
186
177
|
private resolve;
|
|
187
178
|
private run;
|
|
188
179
|
}
|
|
@@ -202,7 +193,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
202
193
|
* — note that a failing limiter then permits every request through.
|
|
203
194
|
*/
|
|
204
195
|
failOpen?: boolean;
|
|
205
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
198
|
+
*
|
|
199
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
200
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
201
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
202
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
203
|
+
* shared bucket is a visible choice.
|
|
204
|
+
*/
|
|
206
205
|
key?: (context: Context) => string | undefined;
|
|
207
206
|
/** Override the error message thrown on rejection. */
|
|
208
207
|
message?: string;
|
|
@@ -211,7 +210,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
211
210
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
211
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
212
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
-
* hits) carrying `
|
|
213
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
215
214
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
215
|
*
|
|
217
216
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -278,21 +277,33 @@ interface RateLimitDatabaseQuery {
|
|
|
278
277
|
first: () => Promise<Record<string, unknown> | null>;
|
|
279
278
|
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
280
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* The READ slice — everything the store needs to answer `RateLimiter.getValue`
|
|
282
|
+
* and `check`, which the docs describe as projecting the stored value forward to
|
|
283
|
+
* the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
|
|
284
|
+
*
|
|
285
|
+
* Split out because requiring the writer for a pure read meant "how many
|
|
286
|
+
* requests does this user have left" could not be answered from a query context
|
|
287
|
+
* at all — every remaining-quota display had to cast, and a cast that appears
|
|
288
|
+
* often enough stops carrying information. The distinction was already in the
|
|
289
|
+
* methods: `getValue`/`check` read, `limit`/`reset` write.
|
|
290
|
+
*/
|
|
291
|
+
interface RateLimitDatabaseReader {
|
|
292
|
+
query: (table: string) => RateLimitDatabaseQuery;
|
|
293
|
+
}
|
|
281
294
|
/**
|
|
282
295
|
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
283
296
|
* needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
|
|
284
297
|
* directly — declared here (rather than imported) to keep `@lunora/ratelimit`
|
|
285
298
|
* free of a runtime dependency on `@lunora/server`.
|
|
286
299
|
*/
|
|
287
|
-
interface RateLimitDatabase {
|
|
300
|
+
interface RateLimitDatabase extends RateLimitDatabaseReader {
|
|
288
301
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
289
302
|
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
290
303
|
patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
|
|
291
|
-
query: (table: string) => RateLimitDatabaseQuery;
|
|
292
304
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
db: RateLimitDatabase;
|
|
305
|
+
/** The table/column/index knobs shared by the read-only and read-write stores. */
|
|
306
|
+
interface DatabaseStoreLocation {
|
|
296
307
|
/** Index that resolves a row by its key column. Defaults to `by_key`. */
|
|
297
308
|
index?: string;
|
|
298
309
|
/** Column storing the opaque key. Defaults to `key`. */
|
|
@@ -300,6 +311,14 @@ interface DatabaseStoreOptions {
|
|
|
300
311
|
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
301
312
|
table?: string;
|
|
302
313
|
}
|
|
314
|
+
interface DatabaseStoreOptions extends DatabaseStoreLocation {
|
|
315
|
+
/** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
|
|
316
|
+
db: RateLimitDatabase;
|
|
317
|
+
}
|
|
318
|
+
interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
319
|
+
/** The Lunora ORM reader — `ctx.db` inside a query. */
|
|
320
|
+
db: RateLimitDatabaseReader;
|
|
321
|
+
}
|
|
303
322
|
/**
|
|
304
323
|
* Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
|
|
305
324
|
* inside a procedure (the procedure context exposes no raw SQL). Declare a
|
|
@@ -316,8 +335,29 @@ interface DatabaseStoreOptions {
|
|
|
316
335
|
*
|
|
317
336
|
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
318
337
|
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
338
|
+
*
|
|
339
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
340
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
341
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
342
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
343
|
+
* consume from an action, where each write commits on its own, or return a
|
|
344
|
+
* failure value from the mutation instead of throwing.
|
|
319
345
|
*/
|
|
320
346
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
347
|
+
/**
|
|
348
|
+
* Read-only counterpart to {@link createDatabaseStore}, for a query context.
|
|
349
|
+
*
|
|
350
|
+
* `get` behaves identically — same table, index and key column — so
|
|
351
|
+
* `RateLimiter.getValue` / `check` report exactly what the writing store would.
|
|
352
|
+
* `set` and `delete` are the only difference: they throw rather than silently
|
|
353
|
+
* doing nothing, because a limiter that appears to consume budget and does not
|
|
354
|
+
* is worse than one that refuses.
|
|
355
|
+
*
|
|
356
|
+
* This mirrors the split Lunora already makes for `ctx.storage`, which is a
|
|
357
|
+
* `ReadOnlyStorage` in a query and a full `Storage` in an action — the
|
|
358
|
+
* capability difference is visible in the type instead of discovered at runtime.
|
|
359
|
+
*/
|
|
360
|
+
declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOptions) => RateLimitStore;
|
|
321
361
|
/**
|
|
322
362
|
* DB-backed rate-limit middleware sugar. Collapses the common
|
|
323
363
|
*
|
|
@@ -336,6 +376,11 @@ declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitS
|
|
|
336
376
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
337
377
|
* autocompletes to its declared limit names.
|
|
338
378
|
*
|
|
379
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
380
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
381
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
382
|
+
* {@link createDatabaseStore}.
|
|
383
|
+
*
|
|
339
384
|
* Re-exported as `dbRateLimit` from the package root.
|
|
340
385
|
*
|
|
341
386
|
* ```ts
|
|
@@ -386,7 +431,7 @@ interface RatelimitApiContext<Context> {
|
|
|
386
431
|
*
|
|
387
432
|
* ```ts
|
|
388
433
|
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
389
|
-
* const c = initLunora.dataModel
|
|
434
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
390
435
|
* export const send = c.mutation
|
|
391
436
|
* .use(ratelimitPlugin(limiter).middleware!)
|
|
392
437
|
* .mutation(async ({ ctx, args }) => {
|
|
@@ -405,5 +450,50 @@ interface RatelimitApiContext<Context> {
|
|
|
405
450
|
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
406
451
|
*/
|
|
407
452
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
453
|
+
/** A budget bound to one named limit — check before the call, record after it. */
|
|
454
|
+
interface TokenBudget {
|
|
455
|
+
/**
|
|
456
|
+
* Peek at the budget before spending. `ok: false` means it is exhausted:
|
|
457
|
+
* refuse the call, and `retryAfter` says when it refills. Consumes nothing.
|
|
458
|
+
*/
|
|
459
|
+
check: (key: string) => Promise<RateLimitStatus>;
|
|
460
|
+
/**
|
|
461
|
+
* Charge the tokens a call actually used. Always call it, including when the
|
|
462
|
+
* call THREW — a failed generation that consumed input tokens still has to be
|
|
463
|
+
* paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
|
|
464
|
+
* nothing.
|
|
465
|
+
*
|
|
466
|
+
* The charge is a reservation, so it may take the bucket negative: the tokens
|
|
467
|
+
* are already spent, and refusing to record them would let a single oversized
|
|
468
|
+
* call escape the budget entirely.
|
|
469
|
+
*/
|
|
470
|
+
record: (key: string, tokens: number) => Promise<RateLimitStatus>;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Bind a {@link TokenBudget} to one of a limiter's named limits.
|
|
474
|
+
*
|
|
475
|
+
* ```ts
|
|
476
|
+
* const budget = tokenBudget(limiter, "tokens");
|
|
477
|
+
* const allowed = await budget.check(userId);
|
|
478
|
+
*
|
|
479
|
+
* if (!allowed.ok) {
|
|
480
|
+
* throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
|
|
481
|
+
* }
|
|
482
|
+
*
|
|
483
|
+
* try {
|
|
484
|
+
* const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
|
|
485
|
+
*
|
|
486
|
+
* await budget.record(userId, usage?.totalTokens ?? 0);
|
|
487
|
+
*
|
|
488
|
+
* return text;
|
|
489
|
+
* } catch (error) {
|
|
490
|
+
* // The prompt was still sent — charge what is known, then rethrow.
|
|
491
|
+
* await budget.record(userId, estimatedInputTokens);
|
|
492
|
+
*
|
|
493
|
+
* throw error;
|
|
494
|
+
* }
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
|
|
408
498
|
declare const VERSION = "0.0.0";
|
|
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 };
|
|
499
|
+
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, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, type TokenBudget, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin, tokenBudget };
|
package/dist/index.d.ts
CHANGED
|
@@ -19,18 +19,6 @@ interface RateLimitConfig {
|
|
|
19
19
|
period: number;
|
|
20
20
|
/** Tokens granted per `period`. */
|
|
21
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
|
-
*/
|
|
33
|
-
shards?: number;
|
|
34
22
|
/**
|
|
35
23
|
* Phase offset in epoch milliseconds for windowed algorithms — windows
|
|
36
24
|
* align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
|
|
@@ -135,11 +123,15 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
135
123
|
denyList?: Iterable<string>;
|
|
136
124
|
/**
|
|
137
125
|
* Optional key normalizer applied to every incoming `args.key` (the
|
|
138
|
-
* deny-list check
|
|
139
|
-
*
|
|
140
|
-
* IPs/emails so equivalent inputs share a single bucket. The deny-list
|
|
126
|
+
* deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
127
|
+
* emails so equivalent inputs share a single bucket. The deny-list
|
|
141
128
|
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
129
|
* match.
|
|
130
|
+
*
|
|
131
|
+
* After it, a key that is an IPv6 address is always collapsed to its /64
|
|
132
|
+
* (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
|
|
133
|
+
* `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
|
|
134
|
+
* delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
|
|
143
135
|
*/
|
|
144
136
|
normalize?: (key: string) => string;
|
|
145
137
|
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
@@ -166,9 +158,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
166
158
|
* Read the current config and the units admittable right now for a
|
|
167
159
|
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
160
|
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
169
|
-
* the last persisted figure.
|
|
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.
|
|
161
|
+
* the last persisted figure.
|
|
172
162
|
*/
|
|
173
163
|
getValue(name: Names, args?: {
|
|
174
164
|
key?: string;
|
|
@@ -183,6 +173,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
183
173
|
reset(name: Names, args?: {
|
|
184
174
|
key?: string;
|
|
185
175
|
}): Promise<void>;
|
|
176
|
+
private normalizeKey;
|
|
186
177
|
private resolve;
|
|
187
178
|
private run;
|
|
188
179
|
}
|
|
@@ -202,7 +193,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
202
193
|
* — note that a failing limiter then permits every request through.
|
|
203
194
|
*/
|
|
204
195
|
failOpen?: boolean;
|
|
205
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
198
|
+
*
|
|
199
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
200
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
201
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
202
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
203
|
+
* shared bucket is a visible choice.
|
|
204
|
+
*/
|
|
206
205
|
key?: (context: Context) => string | undefined;
|
|
207
206
|
/** Override the error message thrown on rejection. */
|
|
208
207
|
message?: string;
|
|
@@ -211,7 +210,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
211
210
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
211
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
212
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
-
* hits) carrying `
|
|
213
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
215
214
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
215
|
*
|
|
217
216
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -278,21 +277,33 @@ interface RateLimitDatabaseQuery {
|
|
|
278
277
|
first: () => Promise<Record<string, unknown> | null>;
|
|
279
278
|
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
280
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* The READ slice — everything the store needs to answer `RateLimiter.getValue`
|
|
282
|
+
* and `check`, which the docs describe as projecting the stored value forward to
|
|
283
|
+
* the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
|
|
284
|
+
*
|
|
285
|
+
* Split out because requiring the writer for a pure read meant "how many
|
|
286
|
+
* requests does this user have left" could not be answered from a query context
|
|
287
|
+
* at all — every remaining-quota display had to cast, and a cast that appears
|
|
288
|
+
* often enough stops carrying information. The distinction was already in the
|
|
289
|
+
* methods: `getValue`/`check` read, `limit`/`reset` write.
|
|
290
|
+
*/
|
|
291
|
+
interface RateLimitDatabaseReader {
|
|
292
|
+
query: (table: string) => RateLimitDatabaseQuery;
|
|
293
|
+
}
|
|
281
294
|
/**
|
|
282
295
|
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
283
296
|
* needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
|
|
284
297
|
* directly — declared here (rather than imported) to keep `@lunora/ratelimit`
|
|
285
298
|
* free of a runtime dependency on `@lunora/server`.
|
|
286
299
|
*/
|
|
287
|
-
interface RateLimitDatabase {
|
|
300
|
+
interface RateLimitDatabase extends RateLimitDatabaseReader {
|
|
288
301
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
289
302
|
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
290
303
|
patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
|
|
291
|
-
query: (table: string) => RateLimitDatabaseQuery;
|
|
292
304
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
db: RateLimitDatabase;
|
|
305
|
+
/** The table/column/index knobs shared by the read-only and read-write stores. */
|
|
306
|
+
interface DatabaseStoreLocation {
|
|
296
307
|
/** Index that resolves a row by its key column. Defaults to `by_key`. */
|
|
297
308
|
index?: string;
|
|
298
309
|
/** Column storing the opaque key. Defaults to `key`. */
|
|
@@ -300,6 +311,14 @@ interface DatabaseStoreOptions {
|
|
|
300
311
|
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
301
312
|
table?: string;
|
|
302
313
|
}
|
|
314
|
+
interface DatabaseStoreOptions extends DatabaseStoreLocation {
|
|
315
|
+
/** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
|
|
316
|
+
db: RateLimitDatabase;
|
|
317
|
+
}
|
|
318
|
+
interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
319
|
+
/** The Lunora ORM reader — `ctx.db` inside a query. */
|
|
320
|
+
db: RateLimitDatabaseReader;
|
|
321
|
+
}
|
|
303
322
|
/**
|
|
304
323
|
* Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
|
|
305
324
|
* inside a procedure (the procedure context exposes no raw SQL). Declare a
|
|
@@ -316,8 +335,29 @@ interface DatabaseStoreOptions {
|
|
|
316
335
|
*
|
|
317
336
|
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
318
337
|
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
338
|
+
*
|
|
339
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
340
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
341
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
342
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
343
|
+
* consume from an action, where each write commits on its own, or return a
|
|
344
|
+
* failure value from the mutation instead of throwing.
|
|
319
345
|
*/
|
|
320
346
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
347
|
+
/**
|
|
348
|
+
* Read-only counterpart to {@link createDatabaseStore}, for a query context.
|
|
349
|
+
*
|
|
350
|
+
* `get` behaves identically — same table, index and key column — so
|
|
351
|
+
* `RateLimiter.getValue` / `check` report exactly what the writing store would.
|
|
352
|
+
* `set` and `delete` are the only difference: they throw rather than silently
|
|
353
|
+
* doing nothing, because a limiter that appears to consume budget and does not
|
|
354
|
+
* is worse than one that refuses.
|
|
355
|
+
*
|
|
356
|
+
* This mirrors the split Lunora already makes for `ctx.storage`, which is a
|
|
357
|
+
* `ReadOnlyStorage` in a query and a full `Storage` in an action — the
|
|
358
|
+
* capability difference is visible in the type instead of discovered at runtime.
|
|
359
|
+
*/
|
|
360
|
+
declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOptions) => RateLimitStore;
|
|
321
361
|
/**
|
|
322
362
|
* DB-backed rate-limit middleware sugar. Collapses the common
|
|
323
363
|
*
|
|
@@ -336,6 +376,11 @@ declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitS
|
|
|
336
376
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
337
377
|
* autocompletes to its declared limit names.
|
|
338
378
|
*
|
|
379
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
380
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
381
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
382
|
+
* {@link createDatabaseStore}.
|
|
383
|
+
*
|
|
339
384
|
* Re-exported as `dbRateLimit` from the package root.
|
|
340
385
|
*
|
|
341
386
|
* ```ts
|
|
@@ -386,7 +431,7 @@ interface RatelimitApiContext<Context> {
|
|
|
386
431
|
*
|
|
387
432
|
* ```ts
|
|
388
433
|
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
389
|
-
* const c = initLunora.dataModel
|
|
434
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
390
435
|
* export const send = c.mutation
|
|
391
436
|
* .use(ratelimitPlugin(limiter).middleware!)
|
|
392
437
|
* .mutation(async ({ ctx, args }) => {
|
|
@@ -405,5 +450,50 @@ interface RatelimitApiContext<Context> {
|
|
|
405
450
|
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
406
451
|
*/
|
|
407
452
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
453
|
+
/** A budget bound to one named limit — check before the call, record after it. */
|
|
454
|
+
interface TokenBudget {
|
|
455
|
+
/**
|
|
456
|
+
* Peek at the budget before spending. `ok: false` means it is exhausted:
|
|
457
|
+
* refuse the call, and `retryAfter` says when it refills. Consumes nothing.
|
|
458
|
+
*/
|
|
459
|
+
check: (key: string) => Promise<RateLimitStatus>;
|
|
460
|
+
/**
|
|
461
|
+
* Charge the tokens a call actually used. Always call it, including when the
|
|
462
|
+
* call THREW — a failed generation that consumed input tokens still has to be
|
|
463
|
+
* paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
|
|
464
|
+
* nothing.
|
|
465
|
+
*
|
|
466
|
+
* The charge is a reservation, so it may take the bucket negative: the tokens
|
|
467
|
+
* are already spent, and refusing to record them would let a single oversized
|
|
468
|
+
* call escape the budget entirely.
|
|
469
|
+
*/
|
|
470
|
+
record: (key: string, tokens: number) => Promise<RateLimitStatus>;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Bind a {@link TokenBudget} to one of a limiter's named limits.
|
|
474
|
+
*
|
|
475
|
+
* ```ts
|
|
476
|
+
* const budget = tokenBudget(limiter, "tokens");
|
|
477
|
+
* const allowed = await budget.check(userId);
|
|
478
|
+
*
|
|
479
|
+
* if (!allowed.ok) {
|
|
480
|
+
* throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
|
|
481
|
+
* }
|
|
482
|
+
*
|
|
483
|
+
* try {
|
|
484
|
+
* const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
|
|
485
|
+
*
|
|
486
|
+
* await budget.record(userId, usage?.totalTokens ?? 0);
|
|
487
|
+
*
|
|
488
|
+
* return text;
|
|
489
|
+
* } catch (error) {
|
|
490
|
+
* // The prompt was still sent — charge what is known, then rethrow.
|
|
491
|
+
* await budget.record(userId, estimatedInputTokens);
|
|
492
|
+
*
|
|
493
|
+
* throw error;
|
|
494
|
+
* }
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
|
|
408
498
|
declare const VERSION = "0.0.0";
|
|
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 };
|
|
499
|
+
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, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, type TokenBudget, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin, tokenBudget };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-
|
|
1
|
+
import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-BSKYrouo.mjs";import{default as i}from"./packem_shared/dbRateLimit-BuvEF1x0.mjs";import{default as l}from"./packem_shared/RateLimitError-9Y5OLxpl.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-CWZur6XE.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-Cbc67DNk.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-ve7GsO2i.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-BRlWn1im.mjs";const e="0.0.0";export{l as RateLimitError,u as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,n as createMemoryStore,L as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin,v as tokenBudget};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";import{STATUS_BY_REASON as a}from"./rateLimit-CWZur6XE.mjs";const s=r=>r.reason==="deny"?"request denied (deny list)":Number.isFinite(r.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(r.retryAfter))}ms`:"rate limit exceeded";class c extends n{reason;retryAfter;constructor(e,t){const{code:i,status:o}=a[e.reason??"rate"];super(i,t??s(e),{name:"RateLimitError",status:o}),this.reason=e.reason,this.retryAfter=e.retryAfter}}export{c as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as l}from"@lunora/errors";import{availableAt as p,evaluate as y}from"./availableAt-BSKYrouo.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as v}from"./createDbStore-ve7GsO2i.mjs";const b=/^[\d.:a-f]+$/iu,w=i=>{const t=i.indexOf("%"),e=t===-1?i:i.slice(0,t);if(!e.includes(":")||!b.test(e))return i;let r;try{r=new URL(`http://[${e}]/`).hostname.slice(1,-1)}catch{return i}const[a="",s]=r.split("::"),n=a===""?[]:a.split(":"),h=s===void 0||s===""?[]:s.split(":"),c=[...n,...Array.from({length:8-n.length-h.length},()=>"0"),...h].map(o=>Number.parseInt(o,16));if(c.slice(0,5).every(o=>o===0)&&c[5]===65535){const[o=0,m=0]=c.slice(6);return[o>>8,o&255,m>>8,m&255].join(".")}return`${c.slice(0,4).map(o=>o.toString(16)).join(":")}::/64`},u=(i,t)=>t===void 0?encodeURIComponent(i):`${encodeURIComponent(i)}:${encodeURIComponent(t)}`,g=i=>i?t=>w(i(t)):w,N="@lunora/ratelimit: new RateLimiter() was built with no explicit `store`, so it falls back to `createMemoryStore()` — an in-process Map. That Map is durable and correctly shared only for calls that land on the same Durable Object instance; it is NOT shared across `.shardBy(...)` shards or `.global()` replicas, and it resets when the DO instance is evicted/restarted. For a limit that must hold across any of those, pass a durable `store` (`createDbStore` or `createSqlStore`) instead. Pass `store: createMemoryStore()` explicitly once you've confirmed the in-memory default is correct here, to silence this warning.";class S{config;denyList;normalize;now;store;constructor(t){this.config=t.config,this.normalize=g(t.normalize),this.denyList=new Set([...t.denyList??[]].flatMap(e=>[e,this.normalize(e)])),this.now=t.now??Date.now,t.store===void 0&&console.warn(N),this.store=t.store??v();for(const[e,r]of Object.entries(this.config)){if(Object.hasOwn(r,"shards"))throw new l("INTERNAL",`rate limit "${e}": \`shards\` is no longer supported — remove it; the limit now enforces its full \`rate\` (lower \`rate\` if you raised it to offset the split)`);if(!Number.isFinite(r.period)||r.period<=0)throw new l("INTERNAL",`rate limit "${e}": period must be a positive number`);if(!Number.isFinite(r.rate)||r.rate<=0)throw new l("INTERNAL",`rate limit "${e}": rate must be a positive number`);if(r.capacity!==void 0&&(!Number.isFinite(r.capacity)||r.capacity<0))throw new l("INTERNAL",`rate limit "${e}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const r=this.resolve(t),a=this.now(),s=u(t,this.normalizeKey(e.key)),n=p(r,await this.store.get(s),a);return{config:r,ts:n.ts,value:n.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){this.resolve(t),await Promise.resolve(this.store.delete(u(t,this.normalizeKey(e.key))))}normalizeKey(t){return t===void 0?void 0:this.normalize(t)}resolve(t){const e=this.config[t];if(!e)throw new l("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,r){const a=this.resolve(t),s=this.normalizeKey(e.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(e.key))){const d={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new f(d);return d}const n=e.count??1;if(!Number.isInteger(n)||n<=0)throw new l("INTERNAL",`rate limit "${t}": count must be a positive integer`);const h=u(t,s),c=await this.store.get(h),{status:o,value:m}=y(a,c,{consume:r,count:n,now:this.now(),reserve:e.reserve??!1});if(m!==void 0&&await this.store.set(h,m),!o.ok&&e.throws)throw new f(o);return o}}export{S as RateLimiter};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as b}from"@lunora/errors";const o=e=>e.capacity??e.rate,E=e=>e.kind==="sliding window"?e.rate:o(e),i=(e,r)=>{throw new b("INTERNAL",`@lunora/ratelimit: requested count ${String(e)} exceeds the limiter capacity ${String(r)}`)},M=(e,r,t)=>{const u=o(e),a=e.rate/e.period,s=r??{ts:t,value:u},c=Math.max(0,t-s.ts);return{available:Math.min(u,s.value+c*a),capacity:u,ratePerMs:a}},w=(e,r,t)=>{const u=e.start??0,a=u+Math.floor((t-u)/e.period)*e.period;if(!r)return{ts:a,value:o(e)};if(r.ts<a){let s=0,c=1;return(r.value<0||e.capacity!==void 0)&&(s=r.value,c=Math.max(1,Math.ceil((a-r.ts)/e.period))),{ts:a,value:Math.min(o(e),s+c*e.rate)}}return{ts:r.ts,value:r.value}},x=(e,r,t)=>{const u=e.start??0,a=u+Math.floor((t-u)/e.period)*e.period,s=t-a,c=(e.period-s)/e.period;let n=0,l=0;return r?.ts===a?(n=r.prev??0,l=r.value):r?.ts===a-e.period&&(n=r.value),{currentCount:l,elapsed:s,previousCount:n,weight:c,windowStart:a}},S=(e,r,t)=>{const{available:u,capacity:a,ratePerMs:s}=M(e,r,t.now);if(u>=t.count){const l={ts:t.now,value:u-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?l:void 0}}const c=t.count-u,n=Math.ceil(c/s);return t.consume&&t.reserve&&t.count<=a?{status:{ok:!0,retryAfter:n},value:{ts:t.now,value:u-t.count}}:(t.count>a&&i(t.count,a),{status:{ok:!1,reason:"rate",retryAfter:n},value:void 0})},y=(e,r,t,u,a,s)=>{const c=d=>w(e,r,t+d*e.period).value>=a;let l=u<0||e.capacity!==void 0?Math.max(1,Math.ceil((a-u)/e.rate)):1;for(let d=0;d<4&&l>1&&c(l-1);d+=1)l-=1;for(let d=0;d<4&&!c(l);d+=1)l+=1;return t+l*e.period-s},W=(e,r,t)=>{const u=o(e),a=w(e,r,t.now);if(a.value>=t.count){const s={ts:a.ts,value:a.value-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?s:void 0}}if(t.consume&&t.reserve&&t.count<=u){const s=a.value-t.count;return{status:{ok:!0,retryAfter:y(e,{ts:a.ts,value:s},a.ts,s,0,t.now)},value:{ts:a.ts,value:s}}}return t.count>u&&i(t.count,u),{status:{ok:!1,reason:"rate",retryAfter:y(e,r,a.ts,a.value,t.count,t.now)},value:void 0}},N=(e,r,t)=>{const u=e.rate,{period:a}=e,{currentCount:s,elapsed:c,previousCount:n,weight:l,windowStart:d}=x(e,r,t.now),m=n*l+s+t.count<=u,h=v=>{const k=u-v-t.count;if(n>0&&k>=0)return Math.ceil(a-c-k*a/n);const A=u-t.count,C=v>0?Math.max(0,a-A*a/v):0;return Math.ceil(a-c+C)};if(m||t.consume&&t.reserve&&t.count<=u){const v={prev:n,ts:d,value:s+t.count};return{status:{ok:!0,retryAfter:m?0:h(v.value)},value:t.consume?v:void 0}}return t.count>u&&i(t.count,u),{status:{ok:!1,reason:"rate",retryAfter:h(s)},value:void 0}},B=(e,r,t)=>{if(e.kind==="token bucket")return{ts:t,value:M(e,r,t).available};if(e.kind==="sliding window"){const{currentCount:u,previousCount:a,weight:s,windowStart:c}=x(e,r,t);return{ts:c,value:Math.max(0,e.rate-(a*s+u))}}return w(e,r,t)},L=(e,r,t)=>e.kind==="token bucket"?S(e,r,t):e.kind==="sliding window"?N(e,r,t):W(e,r,t);export{B as availableAt,E as enforcedCapacity,L as evaluate};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as E}from"@lunora/errors";const p=()=>{const t=new Map;return{delete:e=>{t.delete(e)},get:e=>t.get(e),set:(e,n)=>{t.set(e,n)}}},i=(t,e,...n)=>t.exec.call(t,e,...n).toArray(),T=t=>{const{sql:e}=t,n=t.table??"_lunora_rate_limits";return i(e,`CREATE TABLE IF NOT EXISTS "${n}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:s=>{i(e,`DELETE FROM "${n}" WHERE k = ?`,s)},get:s=>{const c=i(e,`SELECT value, ts, prev FROM "${n}" WHERE k = ?`,s)[0];if(!c)return;const d={ts:c.ts,value:c.value};return c.prev!==null&&(d.prev=c.prev),d},set:(s,l)=>{i(e,`INSERT INTO "${n}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,s,l.value,l.ts,l.prev??null)}}},w=t=>{const{db:e}=t,n=t.table??"rateLimits",s=t.index??"by_key",l=t.keyField??"key",c=new Map,d=async a=>{const r=await e.query(n).withIndex(s,o=>o.eq(l,a)).first();return c.set(a,r?r._id:void 0),r},v=async a=>(c.has(a)||await d(a),c.get(a));return{delete:async a=>{const r=await v(a);r!==void 0&&await e.delete(r),c.delete(a)},get:async a=>{const r=await d(a);if(!r)return;const o={ts:r.ts,value:r.value};return r.prev!==null&&r.prev!==void 0&&(o.prev=r.prev),o},set:async(a,r)=>{const o=await v(a),u={[l]:a,ts:r.ts,value:r.value};r.prev!==void 0&&(u.prev=r.prev),o===void 0?c.set(a,await e.insert(n,u)):await e.patch(o,u)}}},S=t=>{const e=s=>{throw new E("INTERNAL",`@lunora/ratelimit: \`${s}\` needs a writable \`ctx.db\`, but this store was created with \`createReadOnlyDbStore\` (a query context). Use \`createDbStore\` from a mutation or action; a query can only call \`getValue\`/\`check\`.`)},n=w({...t,db:{delete:()=>e("delete"),insert:()=>e("insert"),patch:()=>e("patch"),query:t.db.query.bind(t.db)}});return{delete:()=>e("reset"),get:n.get,set:()=>e("limit")}};export{w as createDbStore,p as createMemoryStore,S as createReadOnlyDbStore,T as createSqlStore};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{rateLimit as o}from"./rateLimit-CWZur6XE.mjs";import{RateLimiter as m}from"./RateLimiter-Cbc67DNk.mjs";import{createDbStore as i}from"./createDbStore-ve7GsO2i.mjs";const f=(e,r,t={})=>o(a=>new m({config:e,store:i({db:a.db,...t.store})}),r,t);export{f as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isLunoraError as c,isInternalCode as l,LunoraError as n}from"@lunora/errors";import{tagPerDispatchMiddleware as f}from"@lunora/server";const v={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},y=(r,t,e)=>t==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,w=(r,t,e)=>{if(!e)return;const a=e(t);if(a===void 0)throw new n("INTERNAL",`@lunora/ratelimit: rateLimit("${r}") key resolver returned undefined; return a fallback such as "anonymous" instead`);return a},h=(r,t,e={})=>f(async({ctx:a,next:d})=>{let i;try{i=await(typeof r=="function"?await r(a):r).limit(t,{count:e.count,key:w(t,a,e.key)})}catch(o){if(c(o)&&l(o.code))throw o;if(console.error(`@lunora/ratelimit: rateLimit("${t}") threw; ${e.failOpen?"failing open":"failing closed"}`,o),e.failOpen)return d();throw new n("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${t}"`,{cause:o,status:503})}if(!i.ok){const o=i.reason??"rate",u=v[o],s=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new n(u.code,e.message??y(t,o,s),{status:u.status,data:s===void 0?void 0:{retryAfterMs:s}})}return d()});export{v as STATUS_BY_REASON,h as rateLimit};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const r=t=>({key:"ratelimit",middleware:async({ctx:e,next:i})=>{const n=typeof t=="function"?await t(e):t,a=e.api??{};return i({ctx:{api:{...a,ratelimit:n}}})}});export{r as ratelimitPlugin};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{enforcedCapacity as n}from"./availableAt-BSKYrouo.mjs";const h=(t,r)=>({check:async c=>t.check(r,{key:c}),record:async(c,a)=>{if(!Number.isFinite(a)||a<=0)return t.check(r,{key:c});const{config:e}=await t.getValue(r,{key:c}),o=Math.max(1,Math.floor(n(e)));return t.limit(r,{count:Math.min(Math.ceil(a),o),key:c,reserve:!0})}});export{h as tokenBudget};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/ratelimit",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list,
|
|
3
|
+
"version": "1.0.0-alpha.120",
|
|
4
|
+
"description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, pluggable stores, and procedure middleware",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
7
7
|
"durable-objects",
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@lunora/server": ">=1.0.0-alpha.24 <2.0.0-0",
|
|
53
|
+
"@lunora/values": ">=1.0.0-alpha.7 <2.0.0-0"
|
|
50
54
|
},
|
|
51
55
|
"engines": {
|
|
52
56
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{LunoraError as i}from"@lunora/errors";import{STATUS_BY_REASON as o}from"./rateLimit-DLcwK-84.mjs";const n=e=>e.reason==="deny"?"request denied (deny list)":Number.isFinite(e.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(e.retryAfter))}ms`:"rate limit exceeded";class c extends i{reason;retryAfter;constructor(r,t){const{code:s,status:a}=o[r.reason??"rate"];super(s,t??n(r),{name:"RateLimitError",status:a}),this.reason=r.reason,this.retryAfter=r.retryAfter}}export{c as default};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{LunoraError as n}from"@lunora/errors";import{availableAt as p,evaluate as N}from"./availableAt-DITZ7jdT.mjs";import d from"./RateLimitError-xJ2Iv-i7.mjs";import{createMemoryStore as g}from"./createDbStore-VpnhCmRx.mjs";const v=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,b=(r,t)=>{let e=0;for(let i=0;i<r.length;i+=1)e=e*31+r.charCodeAt(i)|0;return Math.abs(e)%t},w=(r,t)=>t>1?{...r,capacity:(r.capacity??r.rate)/t,rate:r.rate/t}:r,I=(r,t,e)=>{const i=v(r,t);return e>1?Array.from({length:e},(o,s)=>`${i}#${String(s)}`):[i]},f=(r,t,e)=>{const i=v(r,t);return e>1?`${i}#${String(b(i,e))}`:i};class R{config;denyList;normalize;now;store;constructor(t){this.config=t.config,this.denyList=new Set(t.denyList),this.normalize=t.normalize??(e=>e),this.now=t.now??Date.now,this.store=t.store??g();for(const[e,i]of Object.entries(this.config)){if(i.shards!==void 0&&(!Number.isInteger(i.shards)||i.shards<1))throw new n("INTERNAL",`rate limit "${e}": shards must be a positive integer`);if(!Number.isFinite(i.period)||i.period<=0)throw new n("INTERNAL",`rate limit "${e}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new n("INTERNAL",`rate limit "${e}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new n("INTERNAL",`rate limit "${e}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const i=this.resolve(t),o=i.shards??1,s=this.now(),a=e.key===void 0?void 0:this.normalize(e.key),m=f(t,a,o),c=p(w(i,o),await this.store.get(m),s);return{config:i,ts:c.ts,value:c.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){const i=this.resolve(t).shards??1,o=e.key===void 0?void 0:this.normalize(e.key);await Promise.all(I(t,o,i).map(s=>Promise.resolve(this.store.delete(s))))}resolve(t){const e=this.config[t];if(!e)throw new n("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,i){const o=this.resolve(t),s=e.key===void 0?void 0:this.normalize(e.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(e.key))){const l={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new d(l);return l}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new n("INTERNAL",`rate limit "${t}": count must be a positive integer`);const m=o.shards??1,c=f(t,s,m),y=await this.store.get(c),{status:h,value:u}=N(w(o,m),y,{consume:i,count:a,now:this.now(),reserve:e.reserve??!1});if(u!==void 0&&await this.store.set(c,u),!h.ok&&e.throws)throw new d(h);return h}}export{R as RateLimiter};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{LunoraError as l}from"@lunora/errors";const v=r=>r.capacity??r.rate,p=(r,e,t)=>{const n=v(r),u=r.rate/r.period,o=e??{ts:t,value:n},a=Math.max(0,t-o.ts);return{available:Math.min(n,o.value+a*u),capacity:n,ratePerMs:u}},w=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period;if(!e||e.ts<u){let o=0;return e&&(e.value<0||r.capacity!==void 0)&&(o=e.value),{ts:u,value:Math.min(v(r),o+r.rate)}}return{ts:e.ts,value:e.value}},h=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period,o=t-u,a=(r.period-o)/r.period;let i=0,s=0;return e?.ts===u?(i=e.prev??0,s=e.value):e?.ts===u-r.period&&(i=e.value),{currentCount:s,elapsed:o,previousCount:i,weight:a,windowStart:u}},A=(r,e,t)=>{const{available:n,capacity:u,ratePerMs:o}=p(r,e,t.now);if(n>=t.count){const s={ts:t.now,value:n-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?s:void 0}}const a=t.count-n,i=Math.ceil(a/o);if(t.consume&&t.reserve&&t.count<=u)return{status:{ok:!0,retryAfter:i},value:{ts:t.now,value:n-t.count}};if(t.count>u)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(u)}`);return{status:{ok:!1,reason:"rate",retryAfter:i},value:void 0}},M=(r,e,t)=>{const n=v(r),u=w(r,e,t.now);if(u.value>=t.count){const a={ts:u.ts,value:u.value-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?a:void 0}}const o=u.ts+r.period-t.now;if(t.consume&&t.reserve&&t.count<=n)return{status:{ok:!0,retryAfter:o},value:{ts:u.ts,value:u.value-t.count}};if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:o},value:void 0}},g=(r,e,t)=>{const n=r.rate,{period:u}=r,{currentCount:o,elapsed:a,previousCount:i,weight:s,windowStart:m}=h(r,e,t.now),d=i*s+o+t.count<=n,f=()=>{const c=n-o-t.count;if(i>0&&c>=0)return Math.ceil(u-a-c*u/i);const k=n-t.count,y=o>0?Math.max(0,u-k*u/o):0;return Math.ceil(u-a+y)};if(d||t.consume&&t.reserve&&t.count<=n){const c={prev:i,ts:m,value:o+t.count};return{status:{ok:!0,retryAfter:d?0:f()},value:t.consume?c:void 0}}if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:f()},value:void 0}},b=(r,e,t)=>{if(r.kind==="token bucket")return{ts:t,value:p(r,e,t).available};if(r.kind==="sliding window"){const{currentCount:n,previousCount:u,weight:o,windowStart:a}=h(r,e,t);return{ts:a,value:Math.max(0,r.rate-(u*o+n))}}return w(r,e,t)},x=(r,e,t)=>r.kind==="token bucket"?A(r,e,t):r.kind==="sliding window"?g(r,e,t):M(r,e,t);export{b as availableAt,x as evaluate};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const d=()=>{const a=new Map;return{delete:t=>{a.delete(t)},get:t=>a.get(t),set:(t,l)=>{a.set(t,l)}}},n=(a,t,...l)=>a.exec.call(a,t,...l).toArray(),p=a=>{const{sql:t}=a,l=a.table??"_lunora_rate_limits";return n(t,`CREATE TABLE IF NOT EXISTS "${l}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:u=>{n(t,`DELETE FROM "${l}" WHERE k = ?`,u)},get:u=>{const s=n(t,`SELECT value, ts, prev FROM "${l}" WHERE k = ?`,u)[0];if(!s)return;const v={ts:s.ts,value:s.value};return s.prev!==null&&(v.prev=s.prev),v},set:(u,s)=>{n(t,`INSERT INTO "${l}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,u,s.value,s.ts,s.prev??null)}}},T=a=>{const{db:t}=a,l=a.table??"rateLimits",u=a.index??"by_key",s=a.keyField??"key",v=new Map,o=async r=>{const e=await t.query(l).withIndex(u,i=>i.eq(s,r)).first();return v.set(r,e?e._id:void 0),e},E=async r=>(v.has(r)||await o(r),v.get(r));return{delete:async r=>{const e=await E(r);e!==void 0&&await t.delete(e),v.delete(r)},get:async r=>{const e=await o(r);if(!e)return;const i={ts:e.ts,value:e.value};return e.prev!==null&&e.prev!==void 0&&(i.prev=e.prev),i},set:async(r,e)=>{const i=await E(r),c={[s]:r,ts:e.ts,value:e.value};e.prev!==void 0&&(c.prev=e.prev),i===void 0?v.set(r,await t.insert(l,c)):await t.patch(i,c)}}};export{T as createDbStore,d as createMemoryStore,p as createSqlStore};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{rateLimit as a}from"./rateLimit-DLcwK-84.mjs";import{RateLimiter as i}from"./RateLimiter-BcVZuxFA.mjs";import{createDbStore as m}from"./createDbStore-VpnhCmRx.mjs";const b=(t,o,r={})=>a(e=>new i({config:t,store:m({db:e.db,...r.store})}),o,r);export{b as default};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{isLunoraError as f,isInternalCode as l,LunoraError as c}from"@lunora/errors";const u={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},m=(r,a,e)=>a==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,A=(r,a,e={})=>async({ctx:n,next:s})=>{let i;try{i=await(typeof r=="function"?await r(n):r).limit(a,{count:e.count,key:e.key?.(n)})}catch(t){if(f(t)&&l(t.code))throw t;if(console.error(`@lunora/ratelimit: rateLimit("${a}") threw; ${e.failOpen?"failing open":"failing closed"}`,t),e.failOpen)return s();throw new c("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${a}"`,{cause:t,status:503})}if(!i.ok){const t=i.reason??"rate",d=u[t],o=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new c(d.code,e.message??m(a,t,o),{status:d.status,data:o===void 0?void 0:{retryAfter:o}})}return s()};export{u as STATUS_BY_REASON,A as rateLimit};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
const r=t=>({key:"ratelimit",middleware:async({ctx:i,next:a})=>{const n=typeof t=="function"?await t(i):t,e=i.api??{};return a({ctx:{api:{...e,ratelimit:n}}})}});export{r as ratelimitPlugin};
|