@lunora/ratelimit 1.0.0-alpha.14 → 1.0.0-alpha.141
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 +211 -63
- package/dist/index.d.ts +211 -63
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/RateLimitError-9Y5OLxpl.mjs +1 -0
- package/dist/packem_shared/RateLimiter-DUQPC6Cc.mjs +1 -0
- package/dist/packem_shared/availableAt-BhJ1lMBZ.mjs +1 -0
- package/dist/packem_shared/createDbStore-fSBhI7xO.mjs +1 -0
- package/dist/packem_shared/dbRateLimit-DYnFEJdq.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-CvkodZ0R.mjs +1 -0
- package/package.json +7 -3
- package/dist/packem_shared/RateLimitError-xJ2Iv-i7.mjs +0 -1
- package/dist/packem_shared/RateLimiter-uPXzD0St.mjs +0 -1
- package/dist/packem_shared/availableAt-DITZ7jdT.mjs +0 -1
- package/dist/packem_shared/createDbStore-CwJBMxDQ.mjs +0 -1
- package/dist/packem_shared/dbRateLimit-CrjZatIs.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`.
|
|
@@ -92,6 +80,34 @@ interface RateLimitStore {
|
|
|
92
80
|
delete: (storageKey: string) => Promise<void> | void;
|
|
93
81
|
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
94
82
|
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
83
|
+
/**
|
|
84
|
+
* Optional conditional write: persist `value` only while the stored value is
|
|
85
|
+
* still `prior` (what `get` returned for this consume, `undefined` for no
|
|
86
|
+
* row), and resolve `false` when it no longer is. When present the limiter
|
|
87
|
+
* writes through it instead of `set`, and a `false` makes it read and decide
|
|
88
|
+
* again rather than admit off a stale read.
|
|
89
|
+
*
|
|
90
|
+
* A store whose `get` and write cannot be interleaved by another consume
|
|
91
|
+
* (memory, SQL: synchronous) needs none. One whose calls can be
|
|
92
|
+
* (`createDbStore`: every call is awaited I/O) must have it.
|
|
93
|
+
*/
|
|
94
|
+
setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional eviction hint, called by the limiter after each successful write.
|
|
97
|
+
* Remove up to `limit` entries whose `ts` is at or before `olderThanTs`, except
|
|
98
|
+
* entries with a negative `value`: a reserved debt is kept, whatever its age.
|
|
99
|
+
* The memory store examines up to `limit` entries from its oldest end; the
|
|
100
|
+
* durable stores delete at most `limit` matching rows. A returned promise is
|
|
101
|
+
* awaited, so a store whose sweep is I/O (`createDbStore`) finishes it before the
|
|
102
|
+
* consume returns. The durable stores implement it so a table stays bounded
|
|
103
|
+
* without a scheduled job; see `createSqlStore` and `createDbStore` for the
|
|
104
|
+
* table shape.
|
|
105
|
+
*
|
|
106
|
+
* `owns` says whether a storage key belongs to the limiter asking for the
|
|
107
|
+
* sweep. A store shared with another limiter must leave the other limiter's
|
|
108
|
+
* keys alone: its horizon is not this one's. Absent means every key is owned.
|
|
109
|
+
*/
|
|
110
|
+
sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => Promise<void> | void;
|
|
95
111
|
}
|
|
96
112
|
/** Inputs to {@link evaluate}. */
|
|
97
113
|
interface EvaluateOptions {
|
|
@@ -135,11 +151,15 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
135
151
|
denyList?: Iterable<string>;
|
|
136
152
|
/**
|
|
137
153
|
* 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
|
|
154
|
+
* deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
155
|
+
* emails so equivalent inputs share a single bucket. The deny-list
|
|
141
156
|
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
157
|
* match.
|
|
158
|
+
*
|
|
159
|
+
* After it, a key that is an IPv6 address is always collapsed to its /64
|
|
160
|
+
* (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
|
|
161
|
+
* `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
|
|
162
|
+
* delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
|
|
143
163
|
*/
|
|
144
164
|
normalize?: (key: string) => string;
|
|
145
165
|
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
@@ -158,7 +178,10 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
158
178
|
private readonly denyList;
|
|
159
179
|
private readonly normalize;
|
|
160
180
|
private readonly now;
|
|
181
|
+
private readonly retention;
|
|
161
182
|
private readonly store;
|
|
183
|
+
/** Whether a stored key is one of this limiter's names; a sweep only evicts those. */
|
|
184
|
+
private readonly owns;
|
|
162
185
|
constructor(options: RateLimiterOptions<Names>);
|
|
163
186
|
/** Peek at whether a request would be permitted without consuming. */
|
|
164
187
|
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
@@ -166,9 +189,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
166
189
|
* Read the current config and the units admittable right now for a
|
|
167
190
|
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
191
|
* (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.
|
|
192
|
+
* the last persisted figure.
|
|
172
193
|
*/
|
|
173
194
|
getValue(name: Names, args?: {
|
|
174
195
|
key?: string;
|
|
@@ -183,6 +204,23 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
183
204
|
reset(name: Names, args?: {
|
|
184
205
|
key?: string;
|
|
185
206
|
}): Promise<void>;
|
|
207
|
+
/**
|
|
208
|
+
* Read, evaluate and write one consume. A store that reports the write lost
|
|
209
|
+
* (`setIf` resolved `false`: another consume changed the value since this one
|
|
210
|
+
* read it) gets a fresh read and a fresh decision, so the admission always
|
|
211
|
+
* rests on the value the write replaced. Bounded: each lost round means some
|
|
212
|
+
* other consume was admitted (a refusal writes nothing), and past
|
|
213
|
+
* {@link MAX_DECIDE_ATTEMPTS} the call is refused with `retryAfter: 0` rather
|
|
214
|
+
* than admitted.
|
|
215
|
+
*/
|
|
216
|
+
private decide;
|
|
217
|
+
/**
|
|
218
|
+
* Evict what this limiter's horizon has expired, after a write has landed. The
|
|
219
|
+
* write is already done, so awaiting here does not reopen the read-decide-write
|
|
220
|
+
* window. Only a promise-returning sweep (`createDbStore`) is awaited for I/O.
|
|
221
|
+
*/
|
|
222
|
+
private sweep;
|
|
223
|
+
private normalizeKey;
|
|
186
224
|
private resolve;
|
|
187
225
|
private run;
|
|
188
226
|
}
|
|
@@ -202,7 +240,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
202
240
|
* — note that a failing limiter then permits every request through.
|
|
203
241
|
*/
|
|
204
242
|
failOpen?: boolean;
|
|
205
|
-
/**
|
|
243
|
+
/**
|
|
244
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
245
|
+
*
|
|
246
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
247
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
248
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
249
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
250
|
+
* shared bucket is a visible choice.
|
|
251
|
+
*/
|
|
206
252
|
key?: (context: Context) => string | undefined;
|
|
207
253
|
/** Override the error message thrown on rejection. */
|
|
208
254
|
message?: string;
|
|
@@ -211,7 +257,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
211
257
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
258
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
259
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
-
* hits) carrying `
|
|
260
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
215
261
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
262
|
*
|
|
217
263
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -230,6 +276,9 @@ declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: stri
|
|
|
230
276
|
* In-memory store. State lives for the lifetime of the process (or, inside a
|
|
231
277
|
* Durable Object, the instance) — adequate for single-DO limits but not shared
|
|
232
278
|
* across instances. Use {@link createSqlStore} for durable per-DO state.
|
|
279
|
+
*
|
|
280
|
+
* A store may be shared by several limiters: each sweep evicts only the keys its
|
|
281
|
+
* limiter owns, by that limiter's horizon (see `RateLimitStore.sweep`).
|
|
233
282
|
*/
|
|
234
283
|
declare const createMemoryStore: () => RateLimitStore;
|
|
235
284
|
/**
|
|
@@ -250,34 +299,23 @@ interface SqlStoreOptions {
|
|
|
250
299
|
* SQLite-backed store for durable, per-DO rate-limit state. Persists each
|
|
251
300
|
* `(name, key)` pair as one row so limits survive hibernation and eviction.
|
|
252
301
|
*
|
|
253
|
-
* **
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
302
|
+
* **Retention:** the limiter calls `sweep` after each write, which deletes up to 32
|
|
303
|
+
* rows older than its horizon. The index on `ts` covers only rows with a
|
|
304
|
+
* non-negative `value`, so a reserved debt (never swept) adds nothing to the scan.
|
|
305
|
+
* The table holds the live keys plus the expired ones not yet swept. Limiters may
|
|
306
|
+
* share the table: each sweep deletes only the keys its own limiter owns, so a
|
|
307
|
+
* shorter-window limiter cannot evict a longer-window one's rows. A sweep reads
|
|
308
|
+
* candidates by `ts` before filtering by owner, so a long run of another limiter's
|
|
309
|
+
* expired rows can use up the window for a while.
|
|
310
|
+
*
|
|
311
|
+
* **Atomicity:** `exec` is synchronous and the limiter does not yield between a
|
|
312
|
+
* synchronous store's read and its write, so one consume's read, decision and
|
|
313
|
+
* write run as a single JavaScript step. No other call on the same isolate can
|
|
314
|
+
* interleave, which is what makes concurrent consumes admit exactly the
|
|
315
|
+
* configured rate. Two isolates sharing one SQLite file (not a Durable Object
|
|
316
|
+
* case) are not covered; serialize those yourself.
|
|
262
317
|
*/
|
|
263
318
|
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
|
-
*/
|
|
269
|
-
interface RateLimitDatabaseIndexRange {
|
|
270
|
-
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
271
|
-
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
272
|
-
gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
273
|
-
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
274
|
-
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
275
|
-
}
|
|
276
|
-
/** The slice of a `ctx.db` table query the store relies on. */
|
|
277
|
-
interface RateLimitDatabaseQuery {
|
|
278
|
-
first: () => Promise<Record<string, unknown> | null>;
|
|
279
|
-
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
280
|
-
}
|
|
281
319
|
/**
|
|
282
320
|
* The READ slice — everything the store needs to answer `RateLimiter.getValue`
|
|
283
321
|
* and `check`, which the docs describe as projecting the stored value forward to
|
|
@@ -288,9 +326,16 @@ interface RateLimitDatabaseQuery {
|
|
|
288
326
|
* at all — every remaining-quota display had to cast, and a cast that appears
|
|
289
327
|
* often enough stops carrying information. The distinction was already in the
|
|
290
328
|
* methods: `getValue`/`check` read, `limit`/`reset` write.
|
|
329
|
+
*
|
|
330
|
+
* `findFirst` rather than `query(table).withIndex(...)`: a `.global()` table's
|
|
331
|
+
* reader has no `withIndex`, so the indexed builder failed every read there.
|
|
332
|
+
* `findFirst` routes to whichever backend owns the table, and an equality on the
|
|
333
|
+
* key column still uses its index.
|
|
291
334
|
*/
|
|
292
335
|
interface RateLimitDatabaseReader {
|
|
293
|
-
|
|
336
|
+
findFirst: (table: string, args?: {
|
|
337
|
+
where?: Record<string, unknown>;
|
|
338
|
+
}) => Promise<Record<string, unknown> | null>;
|
|
294
339
|
}
|
|
295
340
|
/**
|
|
296
341
|
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
@@ -300,14 +345,32 @@ interface RateLimitDatabaseReader {
|
|
|
300
345
|
*/
|
|
301
346
|
interface RateLimitDatabase extends RateLimitDatabaseReader {
|
|
302
347
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
303
|
-
|
|
304
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Used by `sweep` only: rows matching `where`, at most `limit`. Optional in the
|
|
350
|
+
* type because the public `DatabaseWriter` does not declare it; the framework's
|
|
351
|
+
* `ctx.db` implements it, and `sweep` throws if an adapter does not.
|
|
352
|
+
*/
|
|
353
|
+
findMany?: (table: string, args: {
|
|
354
|
+
limit?: number;
|
|
355
|
+
orderBy?: Record<string, "asc" | "desc">[];
|
|
356
|
+
where?: Record<string, unknown>;
|
|
357
|
+
}) => Promise<{
|
|
358
|
+
page: Record<string, unknown>[];
|
|
359
|
+
}>;
|
|
360
|
+
insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
|
|
361
|
+
clientId?: string;
|
|
362
|
+
}) => Promise<Id<T>>;
|
|
363
|
+
patchIf: <T extends string>(table: T, id: Id<T>, args: {
|
|
364
|
+
patch: Record<string, unknown>;
|
|
365
|
+
where: Record<string, unknown>;
|
|
366
|
+
}) => Promise<{
|
|
367
|
+
applied: boolean;
|
|
368
|
+
doc: Record<string, unknown> | null;
|
|
369
|
+
}>;
|
|
305
370
|
}
|
|
306
|
-
/** The table/column
|
|
371
|
+
/** The table/column knobs shared by the read-only and read-write stores. */
|
|
307
372
|
interface DatabaseStoreLocation {
|
|
308
|
-
/**
|
|
309
|
-
index?: string;
|
|
310
|
-
/** Column storing the opaque key. Defaults to `key`. */
|
|
373
|
+
/** Column storing the opaque key. Defaults to `key`. Index it: every consume reads by it. */
|
|
311
374
|
keyField?: string;
|
|
312
375
|
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
313
376
|
table?: string;
|
|
@@ -321,9 +384,10 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
321
384
|
db: RateLimitDatabaseReader;
|
|
322
385
|
}
|
|
323
386
|
/**
|
|
324
|
-
* Store backed by a Lunora table through `ctx.db`, for durable
|
|
325
|
-
*
|
|
326
|
-
* table with the key column and its
|
|
387
|
+
* Store backed by a Lunora table through `ctx.db`, for durable limits inside a
|
|
388
|
+
* procedure (the procedure context exposes no raw SQL). Works on a shard-local
|
|
389
|
+
* table and on a `.global()` one. Declare a table with the key column and its
|
|
390
|
+
* index, e.g.
|
|
327
391
|
*
|
|
328
392
|
* ```ts
|
|
329
393
|
* rateLimits: defineTable({
|
|
@@ -331,11 +395,45 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
331
395
|
* ts: v.number(),
|
|
332
396
|
* value: v.number(),
|
|
333
397
|
* prev: v.optional(v.number()),
|
|
334
|
-
* })
|
|
398
|
+
* })
|
|
399
|
+
* .index("by_key", ["key"])
|
|
400
|
+
* .index("by_ts", ["ts"])
|
|
335
401
|
* ```
|
|
336
402
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
403
|
+
* **Table shape change:** `by_ts` is new. The sweep finds expired rows through it.
|
|
404
|
+
* A table declared without it still works, but each sweep scans every row. Apps
|
|
405
|
+
* that already declared the table add the index. The sweep deletes at most 32
|
|
406
|
+
* expired rows per write; a reserved debt (negative `value`) is never deleted.
|
|
407
|
+
* The ORM index cannot be partial, so debts stay in it: many old debts make each
|
|
408
|
+
* sweep read past them. Reserves are rare, so this is accepted.
|
|
409
|
+
*
|
|
410
|
+
* **Ceiling:** the sweep deletes only a row whose `ts`, `value` and `prev` still match
|
|
411
|
+
* what it read, but the framework resolves that match and deletes in two steps.
|
|
412
|
+
* On a `.global()` table another shard can rewrite the row between them, so the
|
|
413
|
+
* row is deleted live and its bucket resets. Closing that needs a conditional
|
|
414
|
+
* delete on `ctx.db`, which does not exist yet. A shard-local table is not exposed:
|
|
415
|
+
* the Durable Object's input gate holds across the sweep's storage operations.
|
|
416
|
+
*
|
|
417
|
+
* **Soft delete:** the sweep deletes with `ctx.db.delete`, which has no hard-delete
|
|
418
|
+
* option. Declare the table without `.softDelete()`: a tombstoned row keeps its
|
|
419
|
+
* deterministic id, so the next consume for that key conflicts forever.
|
|
420
|
+
*
|
|
421
|
+
* **Atomic against concurrent consumes.** `setIf` writes only if the row still
|
|
422
|
+
* holds the value `get` returned: an existing row through `ctx.db.patchIf` with
|
|
423
|
+
* that value as its `where`, a new row through an `insert` under an id derived
|
|
424
|
+
* from the key, which a concurrent creator's insert collides with. A consume
|
|
425
|
+
* that loses either race gets `false` back and the limiter reads and decides
|
|
426
|
+
* again, so a limit of 1 admits one caller however many arrive together — on a
|
|
427
|
+
* `.global()` table too, where D1 calls do not hold the Durable Object's input
|
|
428
|
+
* gate.
|
|
429
|
+
*
|
|
430
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
431
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
432
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
433
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
434
|
+
* consume from an action, where each write commits on its own, or return a
|
|
435
|
+
* failure value from the mutation instead of throwing. A `.global()` table's
|
|
436
|
+
* row lives in D1, outside the DO transaction, so there the unit stays spent.
|
|
339
437
|
*/
|
|
340
438
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
341
439
|
/**
|
|
@@ -364,12 +462,17 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
|
|
|
364
462
|
* on the DO the procedure runs on). Every query/mutation/action ctx exposes a
|
|
365
463
|
* compatible `db`, so it slots straight into a `.use(...)` chain.
|
|
366
464
|
*
|
|
367
|
-
* Pass `options.store` to point at a non-default backing table/
|
|
368
|
-
* (defaults: table `rateLimits`,
|
|
465
|
+
* Pass `options.store` to point at a non-default backing table/key column
|
|
466
|
+
* (defaults: table `rateLimits`, key column `key`); the rest of
|
|
369
467
|
* `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
|
|
370
468
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
371
469
|
* autocompletes to its declared limit names.
|
|
372
470
|
*
|
|
471
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
472
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
473
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
474
|
+
* {@link createDatabaseStore}.
|
|
475
|
+
*
|
|
373
476
|
* Re-exported as `dbRateLimit` from the package root.
|
|
374
477
|
*
|
|
375
478
|
* ```ts
|
|
@@ -420,7 +523,7 @@ interface RatelimitApiContext<Context> {
|
|
|
420
523
|
*
|
|
421
524
|
* ```ts
|
|
422
525
|
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
423
|
-
* const c = initLunora.dataModel
|
|
526
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
424
527
|
* export const send = c.mutation
|
|
425
528
|
* .use(ratelimitPlugin(limiter).middleware!)
|
|
426
529
|
* .mutation(async ({ ctx, args }) => {
|
|
@@ -439,5 +542,50 @@ interface RatelimitApiContext<Context> {
|
|
|
439
542
|
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
440
543
|
*/
|
|
441
544
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
545
|
+
/** A budget bound to one named limit — check before the call, record after it. */
|
|
546
|
+
interface TokenBudget {
|
|
547
|
+
/**
|
|
548
|
+
* Peek at the budget before spending. `ok: false` means it is exhausted:
|
|
549
|
+
* refuse the call, and `retryAfter` says when it refills. Consumes nothing.
|
|
550
|
+
*/
|
|
551
|
+
check: (key: string) => Promise<RateLimitStatus>;
|
|
552
|
+
/**
|
|
553
|
+
* Charge the tokens a call actually used. Always call it, including when the
|
|
554
|
+
* call THREW — a failed generation that consumed input tokens still has to be
|
|
555
|
+
* paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
|
|
556
|
+
* nothing.
|
|
557
|
+
*
|
|
558
|
+
* The charge is a reservation, so it may take the bucket negative: the tokens
|
|
559
|
+
* are already spent, and refusing to record them would let a single oversized
|
|
560
|
+
* call escape the budget entirely.
|
|
561
|
+
*/
|
|
562
|
+
record: (key: string, tokens: number) => Promise<RateLimitStatus>;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Bind a {@link TokenBudget} to one of a limiter's named limits.
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* const budget = tokenBudget(limiter, "tokens");
|
|
569
|
+
* const allowed = await budget.check(userId);
|
|
570
|
+
*
|
|
571
|
+
* if (!allowed.ok) {
|
|
572
|
+
* throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
|
|
573
|
+
* }
|
|
574
|
+
*
|
|
575
|
+
* try {
|
|
576
|
+
* const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
|
|
577
|
+
*
|
|
578
|
+
* await budget.record(userId, usage?.totalTokens ?? 0);
|
|
579
|
+
*
|
|
580
|
+
* return text;
|
|
581
|
+
* } catch (error) {
|
|
582
|
+
* // The prompt was still sent — charge what is known, then rethrow.
|
|
583
|
+
* await budget.record(userId, estimatedInputTokens);
|
|
584
|
+
*
|
|
585
|
+
* throw error;
|
|
586
|
+
* }
|
|
587
|
+
* ```
|
|
588
|
+
*/
|
|
589
|
+
declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
|
|
442
590
|
declare const VERSION = "0.0.0";
|
|
443
|
-
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type
|
|
591
|
+
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, 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`.
|
|
@@ -92,6 +80,34 @@ interface RateLimitStore {
|
|
|
92
80
|
delete: (storageKey: string) => Promise<void> | void;
|
|
93
81
|
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
94
82
|
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
83
|
+
/**
|
|
84
|
+
* Optional conditional write: persist `value` only while the stored value is
|
|
85
|
+
* still `prior` (what `get` returned for this consume, `undefined` for no
|
|
86
|
+
* row), and resolve `false` when it no longer is. When present the limiter
|
|
87
|
+
* writes through it instead of `set`, and a `false` makes it read and decide
|
|
88
|
+
* again rather than admit off a stale read.
|
|
89
|
+
*
|
|
90
|
+
* A store whose `get` and write cannot be interleaved by another consume
|
|
91
|
+
* (memory, SQL: synchronous) needs none. One whose calls can be
|
|
92
|
+
* (`createDbStore`: every call is awaited I/O) must have it.
|
|
93
|
+
*/
|
|
94
|
+
setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional eviction hint, called by the limiter after each successful write.
|
|
97
|
+
* Remove up to `limit` entries whose `ts` is at or before `olderThanTs`, except
|
|
98
|
+
* entries with a negative `value`: a reserved debt is kept, whatever its age.
|
|
99
|
+
* The memory store examines up to `limit` entries from its oldest end; the
|
|
100
|
+
* durable stores delete at most `limit` matching rows. A returned promise is
|
|
101
|
+
* awaited, so a store whose sweep is I/O (`createDbStore`) finishes it before the
|
|
102
|
+
* consume returns. The durable stores implement it so a table stays bounded
|
|
103
|
+
* without a scheduled job; see `createSqlStore` and `createDbStore` for the
|
|
104
|
+
* table shape.
|
|
105
|
+
*
|
|
106
|
+
* `owns` says whether a storage key belongs to the limiter asking for the
|
|
107
|
+
* sweep. A store shared with another limiter must leave the other limiter's
|
|
108
|
+
* keys alone: its horizon is not this one's. Absent means every key is owned.
|
|
109
|
+
*/
|
|
110
|
+
sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => Promise<void> | void;
|
|
95
111
|
}
|
|
96
112
|
/** Inputs to {@link evaluate}. */
|
|
97
113
|
interface EvaluateOptions {
|
|
@@ -135,11 +151,15 @@ interface RateLimiterOptions<Names extends string> {
|
|
|
135
151
|
denyList?: Iterable<string>;
|
|
136
152
|
/**
|
|
137
153
|
* 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
|
|
154
|
+
* deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
155
|
+
* emails so equivalent inputs share a single bucket. The deny-list
|
|
141
156
|
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
157
|
* match.
|
|
158
|
+
*
|
|
159
|
+
* After it, a key that is an IPv6 address is always collapsed to its /64
|
|
160
|
+
* (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
|
|
161
|
+
* `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
|
|
162
|
+
* delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
|
|
143
163
|
*/
|
|
144
164
|
normalize?: (key: string) => string;
|
|
145
165
|
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
@@ -158,7 +178,10 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
158
178
|
private readonly denyList;
|
|
159
179
|
private readonly normalize;
|
|
160
180
|
private readonly now;
|
|
181
|
+
private readonly retention;
|
|
161
182
|
private readonly store;
|
|
183
|
+
/** Whether a stored key is one of this limiter's names; a sweep only evicts those. */
|
|
184
|
+
private readonly owns;
|
|
162
185
|
constructor(options: RateLimiterOptions<Names>);
|
|
163
186
|
/** Peek at whether a request would be permitted without consuming. */
|
|
164
187
|
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
@@ -166,9 +189,7 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
166
189
|
* Read the current config and the units admittable right now for a
|
|
167
190
|
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
191
|
* (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.
|
|
192
|
+
* the last persisted figure.
|
|
172
193
|
*/
|
|
173
194
|
getValue(name: Names, args?: {
|
|
174
195
|
key?: string;
|
|
@@ -183,6 +204,23 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
183
204
|
reset(name: Names, args?: {
|
|
184
205
|
key?: string;
|
|
185
206
|
}): Promise<void>;
|
|
207
|
+
/**
|
|
208
|
+
* Read, evaluate and write one consume. A store that reports the write lost
|
|
209
|
+
* (`setIf` resolved `false`: another consume changed the value since this one
|
|
210
|
+
* read it) gets a fresh read and a fresh decision, so the admission always
|
|
211
|
+
* rests on the value the write replaced. Bounded: each lost round means some
|
|
212
|
+
* other consume was admitted (a refusal writes nothing), and past
|
|
213
|
+
* {@link MAX_DECIDE_ATTEMPTS} the call is refused with `retryAfter: 0` rather
|
|
214
|
+
* than admitted.
|
|
215
|
+
*/
|
|
216
|
+
private decide;
|
|
217
|
+
/**
|
|
218
|
+
* Evict what this limiter's horizon has expired, after a write has landed. The
|
|
219
|
+
* write is already done, so awaiting here does not reopen the read-decide-write
|
|
220
|
+
* window. Only a promise-returning sweep (`createDbStore`) is awaited for I/O.
|
|
221
|
+
*/
|
|
222
|
+
private sweep;
|
|
223
|
+
private normalizeKey;
|
|
186
224
|
private resolve;
|
|
187
225
|
private run;
|
|
188
226
|
}
|
|
@@ -202,7 +240,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
202
240
|
* — note that a failing limiter then permits every request through.
|
|
203
241
|
*/
|
|
204
242
|
failOpen?: boolean;
|
|
205
|
-
/**
|
|
243
|
+
/**
|
|
244
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
245
|
+
*
|
|
246
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
247
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
248
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
249
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
250
|
+
* shared bucket is a visible choice.
|
|
251
|
+
*/
|
|
206
252
|
key?: (context: Context) => string | undefined;
|
|
207
253
|
/** Override the error message thrown on rejection. */
|
|
208
254
|
message?: string;
|
|
@@ -211,7 +257,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
211
257
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
212
258
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
213
259
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
214
|
-
* hits) carrying `
|
|
260
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
215
261
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
216
262
|
*
|
|
217
263
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -230,6 +276,9 @@ declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: stri
|
|
|
230
276
|
* In-memory store. State lives for the lifetime of the process (or, inside a
|
|
231
277
|
* Durable Object, the instance) — adequate for single-DO limits but not shared
|
|
232
278
|
* across instances. Use {@link createSqlStore} for durable per-DO state.
|
|
279
|
+
*
|
|
280
|
+
* A store may be shared by several limiters: each sweep evicts only the keys its
|
|
281
|
+
* limiter owns, by that limiter's horizon (see `RateLimitStore.sweep`).
|
|
233
282
|
*/
|
|
234
283
|
declare const createMemoryStore: () => RateLimitStore;
|
|
235
284
|
/**
|
|
@@ -250,34 +299,23 @@ interface SqlStoreOptions {
|
|
|
250
299
|
* SQLite-backed store for durable, per-DO rate-limit state. Persists each
|
|
251
300
|
* `(name, key)` pair as one row so limits survive hibernation and eviction.
|
|
252
301
|
*
|
|
253
|
-
* **
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
302
|
+
* **Retention:** the limiter calls `sweep` after each write, which deletes up to 32
|
|
303
|
+
* rows older than its horizon. The index on `ts` covers only rows with a
|
|
304
|
+
* non-negative `value`, so a reserved debt (never swept) adds nothing to the scan.
|
|
305
|
+
* The table holds the live keys plus the expired ones not yet swept. Limiters may
|
|
306
|
+
* share the table: each sweep deletes only the keys its own limiter owns, so a
|
|
307
|
+
* shorter-window limiter cannot evict a longer-window one's rows. A sweep reads
|
|
308
|
+
* candidates by `ts` before filtering by owner, so a long run of another limiter's
|
|
309
|
+
* expired rows can use up the window for a while.
|
|
310
|
+
*
|
|
311
|
+
* **Atomicity:** `exec` is synchronous and the limiter does not yield between a
|
|
312
|
+
* synchronous store's read and its write, so one consume's read, decision and
|
|
313
|
+
* write run as a single JavaScript step. No other call on the same isolate can
|
|
314
|
+
* interleave, which is what makes concurrent consumes admit exactly the
|
|
315
|
+
* configured rate. Two isolates sharing one SQLite file (not a Durable Object
|
|
316
|
+
* case) are not covered; serialize those yourself.
|
|
262
317
|
*/
|
|
263
318
|
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
|
-
*/
|
|
269
|
-
interface RateLimitDatabaseIndexRange {
|
|
270
|
-
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
271
|
-
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
272
|
-
gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
273
|
-
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
274
|
-
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
275
|
-
}
|
|
276
|
-
/** The slice of a `ctx.db` table query the store relies on. */
|
|
277
|
-
interface RateLimitDatabaseQuery {
|
|
278
|
-
first: () => Promise<Record<string, unknown> | null>;
|
|
279
|
-
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
280
|
-
}
|
|
281
319
|
/**
|
|
282
320
|
* The READ slice — everything the store needs to answer `RateLimiter.getValue`
|
|
283
321
|
* and `check`, which the docs describe as projecting the stored value forward to
|
|
@@ -288,9 +326,16 @@ interface RateLimitDatabaseQuery {
|
|
|
288
326
|
* at all — every remaining-quota display had to cast, and a cast that appears
|
|
289
327
|
* often enough stops carrying information. The distinction was already in the
|
|
290
328
|
* methods: `getValue`/`check` read, `limit`/`reset` write.
|
|
329
|
+
*
|
|
330
|
+
* `findFirst` rather than `query(table).withIndex(...)`: a `.global()` table's
|
|
331
|
+
* reader has no `withIndex`, so the indexed builder failed every read there.
|
|
332
|
+
* `findFirst` routes to whichever backend owns the table, and an equality on the
|
|
333
|
+
* key column still uses its index.
|
|
291
334
|
*/
|
|
292
335
|
interface RateLimitDatabaseReader {
|
|
293
|
-
|
|
336
|
+
findFirst: (table: string, args?: {
|
|
337
|
+
where?: Record<string, unknown>;
|
|
338
|
+
}) => Promise<Record<string, unknown> | null>;
|
|
294
339
|
}
|
|
295
340
|
/**
|
|
296
341
|
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
@@ -300,14 +345,32 @@ interface RateLimitDatabaseReader {
|
|
|
300
345
|
*/
|
|
301
346
|
interface RateLimitDatabase extends RateLimitDatabaseReader {
|
|
302
347
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
303
|
-
|
|
304
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Used by `sweep` only: rows matching `where`, at most `limit`. Optional in the
|
|
350
|
+
* type because the public `DatabaseWriter` does not declare it; the framework's
|
|
351
|
+
* `ctx.db` implements it, and `sweep` throws if an adapter does not.
|
|
352
|
+
*/
|
|
353
|
+
findMany?: (table: string, args: {
|
|
354
|
+
limit?: number;
|
|
355
|
+
orderBy?: Record<string, "asc" | "desc">[];
|
|
356
|
+
where?: Record<string, unknown>;
|
|
357
|
+
}) => Promise<{
|
|
358
|
+
page: Record<string, unknown>[];
|
|
359
|
+
}>;
|
|
360
|
+
insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
|
|
361
|
+
clientId?: string;
|
|
362
|
+
}) => Promise<Id<T>>;
|
|
363
|
+
patchIf: <T extends string>(table: T, id: Id<T>, args: {
|
|
364
|
+
patch: Record<string, unknown>;
|
|
365
|
+
where: Record<string, unknown>;
|
|
366
|
+
}) => Promise<{
|
|
367
|
+
applied: boolean;
|
|
368
|
+
doc: Record<string, unknown> | null;
|
|
369
|
+
}>;
|
|
305
370
|
}
|
|
306
|
-
/** The table/column
|
|
371
|
+
/** The table/column knobs shared by the read-only and read-write stores. */
|
|
307
372
|
interface DatabaseStoreLocation {
|
|
308
|
-
/**
|
|
309
|
-
index?: string;
|
|
310
|
-
/** Column storing the opaque key. Defaults to `key`. */
|
|
373
|
+
/** Column storing the opaque key. Defaults to `key`. Index it: every consume reads by it. */
|
|
311
374
|
keyField?: string;
|
|
312
375
|
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
313
376
|
table?: string;
|
|
@@ -321,9 +384,10 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
321
384
|
db: RateLimitDatabaseReader;
|
|
322
385
|
}
|
|
323
386
|
/**
|
|
324
|
-
* Store backed by a Lunora table through `ctx.db`, for durable
|
|
325
|
-
*
|
|
326
|
-
* table with the key column and its
|
|
387
|
+
* Store backed by a Lunora table through `ctx.db`, for durable limits inside a
|
|
388
|
+
* procedure (the procedure context exposes no raw SQL). Works on a shard-local
|
|
389
|
+
* table and on a `.global()` one. Declare a table with the key column and its
|
|
390
|
+
* index, e.g.
|
|
327
391
|
*
|
|
328
392
|
* ```ts
|
|
329
393
|
* rateLimits: defineTable({
|
|
@@ -331,11 +395,45 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
331
395
|
* ts: v.number(),
|
|
332
396
|
* value: v.number(),
|
|
333
397
|
* prev: v.optional(v.number()),
|
|
334
|
-
* })
|
|
398
|
+
* })
|
|
399
|
+
* .index("by_key", ["key"])
|
|
400
|
+
* .index("by_ts", ["ts"])
|
|
335
401
|
* ```
|
|
336
402
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
403
|
+
* **Table shape change:** `by_ts` is new. The sweep finds expired rows through it.
|
|
404
|
+
* A table declared without it still works, but each sweep scans every row. Apps
|
|
405
|
+
* that already declared the table add the index. The sweep deletes at most 32
|
|
406
|
+
* expired rows per write; a reserved debt (negative `value`) is never deleted.
|
|
407
|
+
* The ORM index cannot be partial, so debts stay in it: many old debts make each
|
|
408
|
+
* sweep read past them. Reserves are rare, so this is accepted.
|
|
409
|
+
*
|
|
410
|
+
* **Ceiling:** the sweep deletes only a row whose `ts`, `value` and `prev` still match
|
|
411
|
+
* what it read, but the framework resolves that match and deletes in two steps.
|
|
412
|
+
* On a `.global()` table another shard can rewrite the row between them, so the
|
|
413
|
+
* row is deleted live and its bucket resets. Closing that needs a conditional
|
|
414
|
+
* delete on `ctx.db`, which does not exist yet. A shard-local table is not exposed:
|
|
415
|
+
* the Durable Object's input gate holds across the sweep's storage operations.
|
|
416
|
+
*
|
|
417
|
+
* **Soft delete:** the sweep deletes with `ctx.db.delete`, which has no hard-delete
|
|
418
|
+
* option. Declare the table without `.softDelete()`: a tombstoned row keeps its
|
|
419
|
+
* deterministic id, so the next consume for that key conflicts forever.
|
|
420
|
+
*
|
|
421
|
+
* **Atomic against concurrent consumes.** `setIf` writes only if the row still
|
|
422
|
+
* holds the value `get` returned: an existing row through `ctx.db.patchIf` with
|
|
423
|
+
* that value as its `where`, a new row through an `insert` under an id derived
|
|
424
|
+
* from the key, which a concurrent creator's insert collides with. A consume
|
|
425
|
+
* that loses either race gets `false` back and the limiter reads and decides
|
|
426
|
+
* again, so a limit of 1 admits one caller however many arrive together — on a
|
|
427
|
+
* `.global()` table too, where D1 calls do not hold the Durable Object's input
|
|
428
|
+
* gate.
|
|
429
|
+
*
|
|
430
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
431
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
432
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
433
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
434
|
+
* consume from an action, where each write commits on its own, or return a
|
|
435
|
+
* failure value from the mutation instead of throwing. A `.global()` table's
|
|
436
|
+
* row lives in D1, outside the DO transaction, so there the unit stays spent.
|
|
339
437
|
*/
|
|
340
438
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
341
439
|
/**
|
|
@@ -364,12 +462,17 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
|
|
|
364
462
|
* on the DO the procedure runs on). Every query/mutation/action ctx exposes a
|
|
365
463
|
* compatible `db`, so it slots straight into a `.use(...)` chain.
|
|
366
464
|
*
|
|
367
|
-
* Pass `options.store` to point at a non-default backing table/
|
|
368
|
-
* (defaults: table `rateLimits`,
|
|
465
|
+
* Pass `options.store` to point at a non-default backing table/key column
|
|
466
|
+
* (defaults: table `rateLimits`, key column `key`); the rest of
|
|
369
467
|
* `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
|
|
370
468
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
371
469
|
* autocompletes to its declared limit names.
|
|
372
470
|
*
|
|
471
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
472
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
473
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
474
|
+
* {@link createDatabaseStore}.
|
|
475
|
+
*
|
|
373
476
|
* Re-exported as `dbRateLimit` from the package root.
|
|
374
477
|
*
|
|
375
478
|
* ```ts
|
|
@@ -420,7 +523,7 @@ interface RatelimitApiContext<Context> {
|
|
|
420
523
|
*
|
|
421
524
|
* ```ts
|
|
422
525
|
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
423
|
-
* const c = initLunora.dataModel
|
|
526
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
424
527
|
* export const send = c.mutation
|
|
425
528
|
* .use(ratelimitPlugin(limiter).middleware!)
|
|
426
529
|
* .mutation(async ({ ctx, args }) => {
|
|
@@ -439,5 +542,50 @@ interface RatelimitApiContext<Context> {
|
|
|
439
542
|
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
440
543
|
*/
|
|
441
544
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
545
|
+
/** A budget bound to one named limit — check before the call, record after it. */
|
|
546
|
+
interface TokenBudget {
|
|
547
|
+
/**
|
|
548
|
+
* Peek at the budget before spending. `ok: false` means it is exhausted:
|
|
549
|
+
* refuse the call, and `retryAfter` says when it refills. Consumes nothing.
|
|
550
|
+
*/
|
|
551
|
+
check: (key: string) => Promise<RateLimitStatus>;
|
|
552
|
+
/**
|
|
553
|
+
* Charge the tokens a call actually used. Always call it, including when the
|
|
554
|
+
* call THREW — a failed generation that consumed input tokens still has to be
|
|
555
|
+
* paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
|
|
556
|
+
* nothing.
|
|
557
|
+
*
|
|
558
|
+
* The charge is a reservation, so it may take the bucket negative: the tokens
|
|
559
|
+
* are already spent, and refusing to record them would let a single oversized
|
|
560
|
+
* call escape the budget entirely.
|
|
561
|
+
*/
|
|
562
|
+
record: (key: string, tokens: number) => Promise<RateLimitStatus>;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Bind a {@link TokenBudget} to one of a limiter's named limits.
|
|
566
|
+
*
|
|
567
|
+
* ```ts
|
|
568
|
+
* const budget = tokenBudget(limiter, "tokens");
|
|
569
|
+
* const allowed = await budget.check(userId);
|
|
570
|
+
*
|
|
571
|
+
* if (!allowed.ok) {
|
|
572
|
+
* throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
|
|
573
|
+
* }
|
|
574
|
+
*
|
|
575
|
+
* try {
|
|
576
|
+
* const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
|
|
577
|
+
*
|
|
578
|
+
* await budget.record(userId, usage?.totalTokens ?? 0);
|
|
579
|
+
*
|
|
580
|
+
* return text;
|
|
581
|
+
* } catch (error) {
|
|
582
|
+
* // The prompt was still sent — charge what is known, then rethrow.
|
|
583
|
+
* await budget.record(userId, estimatedInputTokens);
|
|
584
|
+
*
|
|
585
|
+
* throw error;
|
|
586
|
+
* }
|
|
587
|
+
* ```
|
|
588
|
+
*/
|
|
589
|
+
declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
|
|
442
590
|
declare const VERSION = "0.0.0";
|
|
443
|
-
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type
|
|
591
|
+
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, 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-BhJ1lMBZ.mjs";import{default as i}from"./packem_shared/dbRateLimit-DYnFEJdq.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-DUQPC6Cc.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-fSBhI7xO.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-CvkodZ0R.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 h}from"@lunora/errors";import{retentionMs as p,availableAt as y,evaluate as v}from"./availableAt-BhJ1lMBZ.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as I}from"./createDbStore-fSBhI7xO.mjs";const b=/^[\d.:a-f]+$/iu,w=r=>{const t=r.indexOf("%"),e=t===-1?r:r.slice(0,t);if(!e.includes(":")||!b.test(e))return r;let s;try{s=new URL(`http://[${e}]/`).hostname.slice(1,-1)}catch{return r}const[i="",n]=s.split("::"),a=i===""?[]:i.split(":"),l=n===void 0||n===""?[]:n.split(":"),c=[...a,...Array.from({length:8-a.length-l.length},()=>"0"),...l].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=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,N=16,g=32,d=r=>typeof r?.then=="function",L=r=>{let t=Number.NEGATIVE_INFINITY;return()=>(t=Math.max(t,r()),t)},R=r=>r?t=>w(r(t)):w,T="@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 M{config;denyList;normalize;now;retention;store;owns;constructor(t){this.config=structuredClone(t.config);const e=new Set(Object.keys(this.config).map(s=>encodeURIComponent(s)));this.owns=s=>e.has(s.split(":")[0]??""),this.normalize=R(t.normalize),this.denyList=new Set([...t.denyList??[]].flatMap(s=>[s,this.normalize(s)])),this.now=L(t.now??Date.now),t.store===void 0&&console.warn(T),this.store=t.store??I(),this.retention=p(Object.values(this.config));for(const[s,i]of Object.entries(this.config)){if(Object.hasOwn(i,"shards"))throw new h("INTERNAL",`rate limit "${s}": \`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(i.period)||i.period<=0)throw new h("INTERNAL",`rate limit "${s}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new h("INTERNAL",`rate limit "${s}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new h("INTERNAL",`rate limit "${s}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const s=this.resolve(t),i=this.now(),n=u(t,this.normalizeKey(e.key)),a=y(s,await this.store.get(n),i);return{config:s,ts:a.ts,value:a.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))))}async decide(t,e,s){for(let i=0;i<N;i+=1){const n=this.store.get(e),a=d(n)?await n:n,l=this.now(),{status:c,value:o}=v(t,a,{...s,now:l});if(o===void 0)return c;if(this.store.setIf===void 0)return await this.store.set(e,o),await this.sweep(l),c;if(await this.store.setIf(e,o,a))return await this.sweep(l),c}return{ok:!1,reason:"rate",retryAfter:0}}async sweep(t){try{const e=this.store.sweep?.(t-this.retention,g,this.owns);d(e)&&await e}catch(e){console.warn("@lunora/ratelimit: the retention sweep failed; the consume still stands",e)}}normalizeKey(t){return t===void 0?void 0:this.normalize(t)}resolve(t){const e=this.config[t];if(!e)throw new h("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,s){const i=this.resolve(t),n=this.normalizeKey(e.key);if(n!==void 0&&(this.denyList.has(n)||this.denyList.has(e.key))){const o={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new f(o);return o}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new h("INTERNAL",`rate limit "${t}": count must be a positive integer`);const l=u(t,n),c=await this.decide(i,l,{consume:s,count:a,reserve:e.reserve??!1});if(!c.ok&&e.throws)throw new f(c);return c}}export{M as RateLimiter};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as b}from"@lunora/errors";const o=t=>t.capacity??t.rate,S=t=>t.kind==="sliding window"?t.rate:o(t),i=(t,a)=>{throw new b("INTERNAL",`@lunora/ratelimit: requested count ${String(t)} exceeds the limiter capacity ${String(a)}`)},M=(t,a,e)=>{const u=o(t),r=t.rate/t.period,s=a??{ts:e,value:u},c=Math.max(0,e-s.ts);return{available:Math.min(u,s.value+c*r),capacity:u,ratePerMs:r}},w=(t,a,e)=>{const u=t.start??0,r=u+Math.floor((e-u)/t.period)*t.period;if(!a)return{ts:r,value:o(t)};if(a.ts<r){let s=0,c=1;return(a.value<0||t.capacity!==void 0)&&(s=a.value,c=Math.max(1,Math.ceil((r-a.ts)/t.period))),{ts:r,value:Math.min(o(t),s+c*t.rate)}}return{ts:a.ts,value:a.value}},x=(t,a,e)=>{const u=t.start??0,r=u+Math.floor((e-u)/t.period)*t.period,s=e-r,c=(t.period-s)/t.period;let n=0,l=0;return a?.ts===r?(n=a.prev??0,l=a.value):a?.ts===r-t.period&&(n=a.value),{currentCount:l,elapsed:s,previousCount:n,weight:c,windowStart:r}},W=(t,a,e)=>{const{available:u,capacity:r,ratePerMs:s}=M(t,a,e.now);if(u>=e.count){const l={ts:e.now,value:u-e.count};return{status:{ok:!0,retryAfter:0},value:e.consume?l:void 0}}const c=e.count-u,n=Math.ceil(c/s);return e.consume&&e.reserve&&e.count<=r?{status:{ok:!0,retryAfter:n},value:{ts:e.now,value:u-e.count}}:(e.count>r&&i(e.count,r),{status:{ok:!1,reason:"rate",retryAfter:n},value:void 0})},y=(t,a,e,u,r,s)=>{const c=d=>w(t,a,e+d*t.period).value>=r;let l=u<0||t.capacity!==void 0?Math.max(1,Math.ceil((r-u)/t.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 e+l*t.period-s},N=(t,a,e)=>{const u=o(t),r=w(t,a,e.now);if(r.value>=e.count){const s={ts:r.ts,value:r.value-e.count};return{status:{ok:!0,retryAfter:0},value:e.consume?s:void 0}}if(e.consume&&e.reserve&&e.count<=u){const s=r.value-e.count;return{status:{ok:!0,retryAfter:y(t,{ts:r.ts,value:s},r.ts,s,0,e.now)},value:{ts:r.ts,value:s}}}return e.count>u&&i(e.count,u),{status:{ok:!1,reason:"rate",retryAfter:y(t,a,r.ts,r.value,e.count,e.now)},value:void 0}},j=(t,a,e)=>{const u=t.rate,{period:r}=t,{currentCount:s,elapsed:c,previousCount:n,weight:l,windowStart:d}=x(t,a,e.now),m=n*l+s+e.count<=u,h=v=>{const k=u-v-e.count;if(n>0&&k>=0)return Math.ceil(r-c-k*r/n);const A=u-e.count,C=v>0?Math.max(0,r-A*r/v):0;return Math.ceil(r-c+C)};if(m||e.consume&&e.reserve&&e.count<=u){const v={prev:n,ts:d,value:s+e.count};return{status:{ok:!0,retryAfter:m?0:h(v.value)},value:e.consume?v:void 0}}return e.count>u&&i(e.count,u),{status:{ok:!1,reason:"rate",retryAfter:h(s)},value:void 0}},B=(t,a,e)=>{if(t.kind==="token bucket")return{ts:e,value:M(t,a,e).available};if(t.kind==="sliding window"){const{currentCount:u,previousCount:r,weight:s,windowStart:c}=x(t,a,e);return{ts:c,value:Math.max(0,t.rate-(r*s+u))}}return w(t,a,e)},L=t=>Math.max(0,...t.map(a=>a.period*(2+S(a)/a.rate))),P=(t,a,e)=>t.kind==="token bucket"?W(t,a,e):t.kind==="sliding window"?j(t,a,e):N(t,a,e);export{B as availableAt,S as enforcedCapacity,P as evaluate,L as retentionMs};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as b}from"@lunora/errors";const L=()=>{const a=new Map;return{delete:e=>{a.delete(e)},get:e=>a.get(e),set:(e,r)=>{a.delete(e),a.set(e,r)},sweep:(e,r,c)=>{let s=0;for(const[l,o]of a){if(s>=r)break;s+=1,a.delete(l),(o.value<0||o.ts>e||c?.(l)===!1)&&a.set(l,o)}}}},v=(a,e,...r)=>a.exec.call(a,e,...r).toArray(),O=a=>{const{sql:e}=a,r=a.table??"_lunora_rate_limits";return v(e,`CREATE TABLE IF NOT EXISTS "${r}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),v(e,`CREATE INDEX IF NOT EXISTS "${r}_ts" ON "${r}" (ts) WHERE value >= 0`),{delete:c=>{v(e,`DELETE FROM "${r}" WHERE k = ?`,c)},sweep:(c,s,l)=>{const o=v(e,`SELECT k FROM "${r}" WHERE ts <= ? AND value >= 0 ORDER BY ts LIMIT ?`,c,s);for(const{k:f}of o)l?.(f)!==!1&&v(e,`DELETE FROM "${r}" WHERE k = ?`,f)},get:c=>{const l=v(e,`SELECT value, ts, prev FROM "${r}" WHERE k = ?`,c)[0];if(!l)return;const o={ts:l.ts,value:l.value};return l.prev!==null&&(o.prev=l.prev),o},set:(c,s)=>{v(e,`INSERT INTO "${r}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,c,s.value,s.ts,s.prev??null)}}},T=a=>a.toString(16).padStart(2,"0"),h=async(a,e)=>{const c=[...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(`${a}\0${e}`))).subarray(0,16)];c[6]=128+c[6]%16,c[8]=128+c[8]%64;const s=c.map(l=>T(l)).join("");return`${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20)}`},I=a=>a?.code==="CONFLICT",R=a=>{const{db:e}=a,r=a.table??"rateLimits",c=a.keyField??"key",s=new Map;let l=!1;const o=async t=>{const n=await e.findFirst(r,{where:{[c]:t}});return s.set(t,n?n._id:void 0),n},f=async t=>(s.has(t)||await o(t),s.get(t)),E=(t,n)=>{const i={[c]:t,ts:n.ts,value:n.value};return n.prev!==void 0&&(i.prev=n.prev),i},p=async(t,n)=>{try{return s.set(t,await e.insert(r,n,{clientId:await h(r,t)})),!0}catch(i){if(I(i))return s.delete(t),!1;throw i}};return{delete:async t=>{const n=await f(t);n!==void 0&&await e.delete(n),s.delete(t)},get:async t=>{const n=await o(t);if(!n)return;const i={ts:n.ts,value:n.value};return n.prev!==null&&n.prev!==void 0&&(i.prev=n.prev),i},set:async(t,n)=>{const i=E(t,n);let u=await f(t);if(u===void 0){if(await p(t,i))return;await o(t),u=s.get(t)}u!==void 0&&await e.patchIf(r,u,{patch:i,where:{}})},setIf:async(t,n,i)=>{const u=E(t,n);if(i===void 0)return p(t,u);const w=await f(t);if(w===void 0)return!1;const{applied:d}=await e.patchIf(r,w,{patch:u,where:{prev:i.prev??null,ts:i.ts,value:i.value}});return d||s.delete(t),d},sweep:async(t,n,i)=>{if(e.findMany===void 0){l||(l=!0,console.warn(`@lunora/ratelimit: table "${r}" is not swept: the adapter has no findMany. Pass the framework's ctx.db.`));return}const{page:u}=await e.findMany(r,{limit:n,orderBy:[{ts:"asc"}],where:{ts:{lte:t},value:{gte:0}}}),w=u.filter(d=>i?.(String(d[c]))!==!1);await Promise.all(w.map(async d=>{await e.findFirst(r,{where:{_id:d._id,prev:d.prev??null,ts:d.ts,value:d.value}})!==null&&await e.delete(d._id)}))}}},y=a=>{const e=c=>{throw new b("INTERNAL",`@lunora/ratelimit: \`${c}\` 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\`.`)},r=R({...a,db:{delete:()=>e("delete"),findFirst:a.db.findFirst.bind(a.db),insert:()=>e("insert"),patchIf:()=>e("patchIf")}});return{delete:()=>e("reset"),get:r.get,set:()=>e("limit"),setIf:()=>e("limit")}};export{R as createDbStore,L as createMemoryStore,y as createReadOnlyDbStore,O as createSqlStore};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{rateLimit as o}from"./rateLimit-CWZur6XE.mjs";import{RateLimiter as m}from"./RateLimiter-DUQPC6Cc.mjs";import{createDbStore as i}from"./createDbStore-fSBhI7xO.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-BhJ1lMBZ.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.141",
|
|
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-CwJBMxDQ.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 p=()=>{const t=new Map;return{delete:e=>{t.delete(e)},get:e=>t.get(e),set:(e,s)=>{t.set(e,s)}}},u=(t,e,...s)=>t.exec.call(t,e,...s).toArray(),y=t=>{const{sql:e}=t,s=t.table??"_lunora_rate_limits";return u(e,`CREATE TABLE IF NOT EXISTS "${s}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:n=>{u(e,`DELETE FROM "${s}" WHERE k = ?`,n)},get:n=>{const l=u(e,`SELECT value, ts, prev FROM "${s}" WHERE k = ?`,n)[0];if(!l)return;const c={ts:l.ts,value:l.value};return l.prev!==null&&(c.prev=l.prev),c},set:(n,l)=>{u(e,`INSERT INTO "${s}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,n,l.value,l.ts,l.prev??null)}}},E=t=>{const{db:e}=t,s=t.table??"rateLimits",n=t.index??"by_key",l=t.keyField??"key",c=new Map,o=async a=>{const r=await e.query(s).withIndex(n,i=>i.eq(l,a)).first();return c.set(a,r?r._id:void 0),r},v=async a=>(c.has(a)||await o(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 o(a);if(!r)return;const i={ts:r.ts,value:r.value};return r.prev!==null&&r.prev!==void 0&&(i.prev=r.prev),i},set:async(a,r)=>{const i=await v(a),d={[l]:a,ts:r.ts,value:r.value};r.prev!==void 0&&(d.prev=r.prev),i===void 0?c.set(a,await e.insert(s,d)):await e.patch(i,d)}}},b=t=>{const e=n=>{throw new Error(`@lunora/ratelimit: \`${n}\` 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\`.`)},s=E({...t,db:{delete:()=>e("delete"),insert:()=>e("insert"),patch:()=>e("patch"),query:t.db.query.bind(t.db)}});return{delete:()=>e("reset"),get:s.get,set:()=>e("limit")}};export{E as createDbStore,p as createMemoryStore,b as createReadOnlyDbStore,y as createSqlStore};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{rateLimit as a}from"./rateLimit-DLcwK-84.mjs";import{RateLimiter as i}from"./RateLimiter-uPXzD0St.mjs";import{createDbStore as m}from"./createDbStore-CwJBMxDQ.mjs";const p=(t,o,r={})=>a(e=>new i({config:t,store:m({db:e.db,...r.store})}),o,r);export{p 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};
|