@lunora/ratelimit 1.0.0-alpha.9 → 1.0.0-alpha.91
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 -11
- 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-CiLy3DsZ.mjs +0 -21
- package/dist/packem_shared/RateLimiter-rDCxu_Nx.mjs +0 -121
- package/dist/packem_shared/availableAt-DQcuVfSA.mjs +0 -114
- package/dist/packem_shared/createDbStore-L1kD1g1n.mjs +0 -103
- package/dist/packem_shared/dbRateLimit-WTU-e0r6.mjs +0 -7
- package/dist/packem_shared/rateLimit-BBdG9GFo.mjs +0 -40
- package/dist/packem_shared/ratelimitPlugin-D5_-KV1T.mjs +0 -12
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,11 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { default as dbRateLimit } from './packem_shared/dbRateLimit-WTU-e0r6.mjs';
|
|
3
|
-
export { default as RateLimitError } from './packem_shared/RateLimitError-CiLy3DsZ.mjs';
|
|
4
|
-
export { rateLimit } from './packem_shared/rateLimit-BBdG9GFo.mjs';
|
|
5
|
-
export { ratelimitPlugin } from './packem_shared/ratelimitPlugin-D5_-KV1T.mjs';
|
|
6
|
-
export { RateLimiter } from './packem_shared/RateLimiter-rDCxu_Nx.mjs';
|
|
7
|
-
export { createDbStore, createMemoryStore, createSqlStore } from './packem_shared/createDbStore-L1kD1g1n.mjs';
|
|
8
|
-
|
|
9
|
-
const VERSION = "0.0.0";
|
|
10
|
-
|
|
11
|
-
export { VERSION };
|
|
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.91",
|
|
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.47"
|
|
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,21 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { STATUS_BY_REASON } from './rateLimit-BBdG9GFo.mjs';
|
|
3
|
-
|
|
4
|
-
const describe = (status) => {
|
|
5
|
-
if (status.reason === "deny") {
|
|
6
|
-
return "request denied (deny list)";
|
|
7
|
-
}
|
|
8
|
-
return Number.isFinite(status.retryAfter) ? `rate limit exceeded; retry after ${String(Math.ceil(status.retryAfter))}ms` : "rate limit exceeded";
|
|
9
|
-
};
|
|
10
|
-
class RateLimitError extends LunoraError {
|
|
11
|
-
reason;
|
|
12
|
-
retryAfter;
|
|
13
|
-
constructor(status, message) {
|
|
14
|
-
const { code, status: httpStatus } = STATUS_BY_REASON[status.reason ?? "rate"];
|
|
15
|
-
super(code, message ?? describe(status), { name: "RateLimitError", status: httpStatus });
|
|
16
|
-
this.reason = status.reason;
|
|
17
|
-
this.retryAfter = status.retryAfter;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export { RateLimitError as default };
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { availableAt, evaluate } from './availableAt-DQcuVfSA.mjs';
|
|
3
|
-
import RateLimitError from './RateLimitError-CiLy3DsZ.mjs';
|
|
4
|
-
import { createMemoryStore } from './createDbStore-L1kD1g1n.mjs';
|
|
5
|
-
|
|
6
|
-
const storageKeyFor = (name, key) => key === void 0 ? encodeURIComponent(name) : `${encodeURIComponent(name)}:${encodeURIComponent(key)}`;
|
|
7
|
-
const hashToShard = (storageKey, shards) => {
|
|
8
|
-
let hash = 0;
|
|
9
|
-
for (let index = 0; index < storageKey.length; index += 1) {
|
|
10
|
-
hash = hash * 31 + storageKey.charCodeAt(index) | 0;
|
|
11
|
-
}
|
|
12
|
-
return Math.abs(hash) % shards;
|
|
13
|
-
};
|
|
14
|
-
const perShardConfig = (config, shards) => shards > 1 ? { ...config, capacity: (config.capacity ?? config.rate) / shards, rate: config.rate / shards } : config;
|
|
15
|
-
const shardKeysFor = (name, key, shards) => {
|
|
16
|
-
const base = storageKeyFor(name, key);
|
|
17
|
-
return shards > 1 ? Array.from({ length: shards }, (_, shard) => `${base}#${String(shard)}`) : [base];
|
|
18
|
-
};
|
|
19
|
-
const routeStorageKey = (name, key, shards) => {
|
|
20
|
-
const base = storageKeyFor(name, key);
|
|
21
|
-
return shards > 1 ? `${base}#${String(hashToShard(base, shards))}` : base;
|
|
22
|
-
};
|
|
23
|
-
class RateLimiter {
|
|
24
|
-
config;
|
|
25
|
-
denyList;
|
|
26
|
-
normalize;
|
|
27
|
-
now;
|
|
28
|
-
store;
|
|
29
|
-
constructor(options) {
|
|
30
|
-
this.config = options.config;
|
|
31
|
-
this.denyList = new Set(options.denyList);
|
|
32
|
-
this.normalize = options.normalize ?? ((key) => key);
|
|
33
|
-
this.now = options.now ?? Date.now;
|
|
34
|
-
this.store = options.store ?? createMemoryStore();
|
|
35
|
-
for (const [name, config] of Object.entries(this.config)) {
|
|
36
|
-
if (config.shards !== void 0 && (!Number.isInteger(config.shards) || config.shards < 1)) {
|
|
37
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}": shards must be a positive integer`);
|
|
38
|
-
}
|
|
39
|
-
if (!Number.isFinite(config.period) || config.period <= 0) {
|
|
40
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}": period must be a positive number`);
|
|
41
|
-
}
|
|
42
|
-
if (!Number.isFinite(config.rate) || config.rate <= 0) {
|
|
43
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}": rate must be a positive number`);
|
|
44
|
-
}
|
|
45
|
-
if (config.capacity !== void 0 && (!Number.isFinite(config.capacity) || config.capacity < 0)) {
|
|
46
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}": capacity must be a non-negative number`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/** Peek at whether a request would be permitted without consuming. */
|
|
51
|
-
async check(name, args = {}) {
|
|
52
|
-
return this.run(name, args, false);
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Read the current config and the units admittable right now for a
|
|
56
|
-
* `(name, key)` pair. The value is projected forward to the current clock
|
|
57
|
-
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
58
|
-
* the last persisted figure. For a sharded limit it reads only the single
|
|
59
|
-
* shard `limit()`/`run()` would route this key to — the sibling shards are
|
|
60
|
-
* never touched by this key, so summing them would over-report.
|
|
61
|
-
*/
|
|
62
|
-
async getValue(name, args = {}) {
|
|
63
|
-
const config = this.resolve(name);
|
|
64
|
-
const shards = config.shards ?? 1;
|
|
65
|
-
const now = this.now();
|
|
66
|
-
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
67
|
-
const storageKey = routeStorageKey(name, normalizedKey, shards);
|
|
68
|
-
const current = availableAt(perShardConfig(config, shards), await this.store.get(storageKey), now);
|
|
69
|
-
return { config, ts: current.ts, value: current.value };
|
|
70
|
-
}
|
|
71
|
-
/** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
|
|
72
|
-
async limit(name, args = {}) {
|
|
73
|
-
return this.run(name, args, true);
|
|
74
|
-
}
|
|
75
|
-
/** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
|
|
76
|
-
async reset(name, args = {}) {
|
|
77
|
-
const shards = this.resolve(name).shards ?? 1;
|
|
78
|
-
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
79
|
-
await Promise.all(shardKeysFor(name, normalizedKey, shards).map((storageKey) => Promise.resolve(this.store.delete(storageKey))));
|
|
80
|
-
}
|
|
81
|
-
resolve(name) {
|
|
82
|
-
const config = this.config[name];
|
|
83
|
-
if (!config) {
|
|
84
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}" is not configured`);
|
|
85
|
-
}
|
|
86
|
-
return config;
|
|
87
|
-
}
|
|
88
|
-
async run(name, args, consume) {
|
|
89
|
-
const config = this.resolve(name);
|
|
90
|
-
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
91
|
-
if (normalizedKey !== void 0 && (this.denyList.has(normalizedKey) || this.denyList.has(args.key))) {
|
|
92
|
-
const status2 = { ok: false, reason: "deny", retryAfter: Number.POSITIVE_INFINITY };
|
|
93
|
-
if (args.throws) {
|
|
94
|
-
throw new RateLimitError(status2);
|
|
95
|
-
}
|
|
96
|
-
return status2;
|
|
97
|
-
}
|
|
98
|
-
const count = args.count ?? 1;
|
|
99
|
-
if (!Number.isInteger(count) || count <= 0) {
|
|
100
|
-
throw new LunoraError("INTERNAL", `rate limit "${name}": count must be a positive integer`);
|
|
101
|
-
}
|
|
102
|
-
const shards = config.shards ?? 1;
|
|
103
|
-
const storageKey = routeStorageKey(name, normalizedKey, shards);
|
|
104
|
-
const prior = await this.store.get(storageKey);
|
|
105
|
-
const { status, value } = evaluate(perShardConfig(config, shards), prior, {
|
|
106
|
-
consume,
|
|
107
|
-
count,
|
|
108
|
-
now: this.now(),
|
|
109
|
-
reserve: args.reserve ?? false
|
|
110
|
-
});
|
|
111
|
-
if (value !== void 0) {
|
|
112
|
-
await this.store.set(storageKey, value);
|
|
113
|
-
}
|
|
114
|
-
if (!status.ok && args.throws) {
|
|
115
|
-
throw new RateLimitError(status);
|
|
116
|
-
}
|
|
117
|
-
return status;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export { RateLimiter };
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const capacityOf = (config) => config.capacity ?? config.rate;
|
|
4
|
-
const projectTokenBucket = (config, prior, now) => {
|
|
5
|
-
const capacity = capacityOf(config);
|
|
6
|
-
const ratePerMs = config.rate / config.period;
|
|
7
|
-
const base = prior ?? { ts: now, value: capacity };
|
|
8
|
-
const elapsed = Math.max(0, now - base.ts);
|
|
9
|
-
return { available: Math.min(capacity, base.value + elapsed * ratePerMs), capacity, ratePerMs };
|
|
10
|
-
};
|
|
11
|
-
const projectFixedWindow = (config, prior, now) => {
|
|
12
|
-
const start = config.start ?? 0;
|
|
13
|
-
const windowStart = start + Math.floor((now - start) / config.period) * config.period;
|
|
14
|
-
if (!prior || prior.ts < windowStart) {
|
|
15
|
-
let carry = 0;
|
|
16
|
-
if (prior && (prior.value < 0 || config.capacity !== void 0)) {
|
|
17
|
-
carry = prior.value;
|
|
18
|
-
}
|
|
19
|
-
return { ts: windowStart, value: Math.min(capacityOf(config), carry + config.rate) };
|
|
20
|
-
}
|
|
21
|
-
return { ts: prior.ts, value: prior.value };
|
|
22
|
-
};
|
|
23
|
-
const projectSlidingWindow = (config, prior, now) => {
|
|
24
|
-
const start = config.start ?? 0;
|
|
25
|
-
const windowStart = start + Math.floor((now - start) / config.period) * config.period;
|
|
26
|
-
const elapsed = now - windowStart;
|
|
27
|
-
const weight = (config.period - elapsed) / config.period;
|
|
28
|
-
let previousCount = 0;
|
|
29
|
-
let currentCount = 0;
|
|
30
|
-
if (prior?.ts === windowStart) {
|
|
31
|
-
previousCount = prior.prev ?? 0;
|
|
32
|
-
currentCount = prior.value;
|
|
33
|
-
} else if (prior?.ts === windowStart - config.period) {
|
|
34
|
-
previousCount = prior.value;
|
|
35
|
-
}
|
|
36
|
-
return { currentCount, elapsed, previousCount, weight, windowStart };
|
|
37
|
-
};
|
|
38
|
-
const tokenBucket = (config, prior, options) => {
|
|
39
|
-
const { available, capacity, ratePerMs } = projectTokenBucket(config, prior, options.now);
|
|
40
|
-
if (available >= options.count) {
|
|
41
|
-
const value = { ts: options.now, value: available - options.count };
|
|
42
|
-
return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
|
|
43
|
-
}
|
|
44
|
-
const deficit = options.count - available;
|
|
45
|
-
const retryAfter = Math.ceil(deficit / ratePerMs);
|
|
46
|
-
if (options.consume && options.reserve && options.count <= capacity) {
|
|
47
|
-
return { status: { ok: true, retryAfter }, value: { ts: options.now, value: available - options.count } };
|
|
48
|
-
}
|
|
49
|
-
if (options.count > capacity) {
|
|
50
|
-
throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
|
|
51
|
-
}
|
|
52
|
-
return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
|
|
53
|
-
};
|
|
54
|
-
const fixedWindow = (config, prior, options) => {
|
|
55
|
-
const capacity = capacityOf(config);
|
|
56
|
-
const base = projectFixedWindow(config, prior, options.now);
|
|
57
|
-
if (base.value >= options.count) {
|
|
58
|
-
const value = { ts: base.ts, value: base.value - options.count };
|
|
59
|
-
return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
|
|
60
|
-
}
|
|
61
|
-
const retryAfter = base.ts + config.period - options.now;
|
|
62
|
-
if (options.consume && options.reserve && options.count <= capacity) {
|
|
63
|
-
return { status: { ok: true, retryAfter }, value: { ts: base.ts, value: base.value - options.count } };
|
|
64
|
-
}
|
|
65
|
-
if (options.count > capacity) {
|
|
66
|
-
throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
|
|
67
|
-
}
|
|
68
|
-
return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
|
|
69
|
-
};
|
|
70
|
-
const slidingWindow = (config, prior, options) => {
|
|
71
|
-
const limit = config.rate;
|
|
72
|
-
const { period } = config;
|
|
73
|
-
const { currentCount, elapsed, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, options.now);
|
|
74
|
-
const estimated = previousCount * weight + currentCount;
|
|
75
|
-
const admit = estimated + options.count <= limit;
|
|
76
|
-
const retryAfter = () => {
|
|
77
|
-
const headroomNow = limit - currentCount - options.count;
|
|
78
|
-
if (previousCount > 0 && headroomNow >= 0) {
|
|
79
|
-
return Math.ceil(period - elapsed - headroomNow * period / previousCount);
|
|
80
|
-
}
|
|
81
|
-
const headroomNext = limit - options.count;
|
|
82
|
-
const intoNext = currentCount > 0 ? Math.max(0, period - headroomNext * period / currentCount) : 0;
|
|
83
|
-
return Math.ceil(period - elapsed + intoNext);
|
|
84
|
-
};
|
|
85
|
-
if (admit || options.consume && options.reserve && options.count <= limit) {
|
|
86
|
-
const value = { prev: previousCount, ts: windowStart, value: currentCount + options.count };
|
|
87
|
-
return { status: { ok: true, retryAfter: admit ? 0 : retryAfter() }, value: options.consume ? value : void 0 };
|
|
88
|
-
}
|
|
89
|
-
if (options.count > limit) {
|
|
90
|
-
throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(limit)}`);
|
|
91
|
-
}
|
|
92
|
-
return { status: { ok: false, reason: "rate", retryAfter: retryAfter() }, value: void 0 };
|
|
93
|
-
};
|
|
94
|
-
const availableAt = (config, prior, now) => {
|
|
95
|
-
if (config.kind === "token bucket") {
|
|
96
|
-
return { ts: now, value: projectTokenBucket(config, prior, now).available };
|
|
97
|
-
}
|
|
98
|
-
if (config.kind === "sliding window") {
|
|
99
|
-
const { currentCount, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, now);
|
|
100
|
-
return { ts: windowStart, value: Math.max(0, config.rate - (previousCount * weight + currentCount)) };
|
|
101
|
-
}
|
|
102
|
-
return projectFixedWindow(config, prior, now);
|
|
103
|
-
};
|
|
104
|
-
const evaluate = (config, prior, options) => {
|
|
105
|
-
if (config.kind === "token bucket") {
|
|
106
|
-
return tokenBucket(config, prior, options);
|
|
107
|
-
}
|
|
108
|
-
if (config.kind === "sliding window") {
|
|
109
|
-
return slidingWindow(config, prior, options);
|
|
110
|
-
}
|
|
111
|
-
return fixedWindow(config, prior, options);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
export { availableAt, evaluate };
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
const createMemoryStore = () => {
|
|
2
|
-
const map = /* @__PURE__ */ new Map();
|
|
3
|
-
return {
|
|
4
|
-
delete: (storageKey) => {
|
|
5
|
-
map.delete(storageKey);
|
|
6
|
-
},
|
|
7
|
-
get: (storageKey) => map.get(storageKey),
|
|
8
|
-
set: (storageKey, value) => {
|
|
9
|
-
map.set(storageKey, value);
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
};
|
|
13
|
-
const runSql = (sql, query, ...params) => {
|
|
14
|
-
const runner = sql.exec;
|
|
15
|
-
return runner.call(sql, query, ...params).toArray();
|
|
16
|
-
};
|
|
17
|
-
const createSqlStore = (options) => {
|
|
18
|
-
const { sql } = options;
|
|
19
|
-
const table = options.table ?? "_lunora_rate_limits";
|
|
20
|
-
runSql(sql, `CREATE TABLE IF NOT EXISTS "${table}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`);
|
|
21
|
-
return {
|
|
22
|
-
delete: (storageKey) => {
|
|
23
|
-
runSql(sql, `DELETE FROM "${table}" WHERE k = ?`, storageKey);
|
|
24
|
-
},
|
|
25
|
-
get: (storageKey) => {
|
|
26
|
-
const rows = runSql(sql, `SELECT value, ts, prev FROM "${table}" WHERE k = ?`, storageKey);
|
|
27
|
-
const row = rows[0];
|
|
28
|
-
if (!row) {
|
|
29
|
-
return void 0;
|
|
30
|
-
}
|
|
31
|
-
const value = { ts: row.ts, value: row.value };
|
|
32
|
-
if (row.prev !== null) {
|
|
33
|
-
value.prev = row.prev;
|
|
34
|
-
}
|
|
35
|
-
return value;
|
|
36
|
-
},
|
|
37
|
-
set: (storageKey, value) => {
|
|
38
|
-
runSql(
|
|
39
|
-
sql,
|
|
40
|
-
`INSERT INTO "${table}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,
|
|
41
|
-
storageKey,
|
|
42
|
-
value.value,
|
|
43
|
-
value.ts,
|
|
44
|
-
// SQL bind: a missing `prev` must bind as SQL NULL, not undefined.
|
|
45
|
-
// eslint-disable-next-line unicorn/no-null -- SQLite/D1 bind parameters require null for a NULL column
|
|
46
|
-
value.prev ?? null
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
};
|
|
51
|
-
const createDatabaseStore = (options) => {
|
|
52
|
-
const { db } = options;
|
|
53
|
-
const table = options.table ?? "rateLimits";
|
|
54
|
-
const index = options.index ?? "by_key";
|
|
55
|
-
const keyField = options.keyField ?? "key";
|
|
56
|
-
const idCache = /* @__PURE__ */ new Map();
|
|
57
|
-
const find = async (storageKey) => {
|
|
58
|
-
const row = await db.query(table).withIndex(index, (q) => q.eq(keyField, storageKey)).first();
|
|
59
|
-
idCache.set(storageKey, row ? row._id : void 0);
|
|
60
|
-
return row;
|
|
61
|
-
};
|
|
62
|
-
const resolveId = async (storageKey) => {
|
|
63
|
-
if (idCache.has(storageKey)) {
|
|
64
|
-
return idCache.get(storageKey);
|
|
65
|
-
}
|
|
66
|
-
await find(storageKey);
|
|
67
|
-
return idCache.get(storageKey);
|
|
68
|
-
};
|
|
69
|
-
return {
|
|
70
|
-
delete: async (storageKey) => {
|
|
71
|
-
const id = await resolveId(storageKey);
|
|
72
|
-
if (id !== void 0) {
|
|
73
|
-
await db.delete(id);
|
|
74
|
-
}
|
|
75
|
-
idCache.delete(storageKey);
|
|
76
|
-
},
|
|
77
|
-
get: async (storageKey) => {
|
|
78
|
-
const row = await find(storageKey);
|
|
79
|
-
if (!row) {
|
|
80
|
-
return void 0;
|
|
81
|
-
}
|
|
82
|
-
const value = { ts: row.ts, value: row.value };
|
|
83
|
-
if (row.prev !== null && row.prev !== void 0) {
|
|
84
|
-
value.prev = row.prev;
|
|
85
|
-
}
|
|
86
|
-
return value;
|
|
87
|
-
},
|
|
88
|
-
set: async (storageKey, value) => {
|
|
89
|
-
const id = await resolveId(storageKey);
|
|
90
|
-
const document = { [keyField]: storageKey, ts: value.ts, value: value.value };
|
|
91
|
-
if (value.prev !== void 0) {
|
|
92
|
-
document.prev = value.prev;
|
|
93
|
-
}
|
|
94
|
-
if (id === void 0) {
|
|
95
|
-
idCache.set(storageKey, await db.insert(table, document));
|
|
96
|
-
} else {
|
|
97
|
-
await db.patch(id, document);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export { createDatabaseStore as createDbStore, createMemoryStore, createSqlStore };
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { rateLimit } from './rateLimit-BBdG9GFo.mjs';
|
|
2
|
-
import { RateLimiter } from './RateLimiter-rDCxu_Nx.mjs';
|
|
3
|
-
import { createDbStore as createDatabaseStore } from './createDbStore-L1kD1g1n.mjs';
|
|
4
|
-
|
|
5
|
-
const databaseRateLimit = (config, name, options = {}) => rateLimit((context) => new RateLimiter({ config, store: createDatabaseStore({ db: context.db, ...options.store }) }), name, options);
|
|
6
|
-
|
|
7
|
-
export { databaseRateLimit as default };
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { isLunoraError, isInternalCode, LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const STATUS_BY_REASON = {
|
|
4
|
-
deny: { code: "FORBIDDEN", status: 403 },
|
|
5
|
-
rate: { code: "TOO_MANY_REQUESTS", status: 429 }
|
|
6
|
-
};
|
|
7
|
-
const defaultMessage = (name, reason, retryAfter) => {
|
|
8
|
-
if (reason === "deny") {
|
|
9
|
-
return `request denied for "${name}"`;
|
|
10
|
-
}
|
|
11
|
-
return retryAfter === void 0 ? `rate limit "${name}" exceeded` : `rate limit "${name}" exceeded; retry after ${String(retryAfter)}ms`;
|
|
12
|
-
};
|
|
13
|
-
const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
|
|
14
|
-
let status;
|
|
15
|
-
try {
|
|
16
|
-
const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
|
|
17
|
-
status = await resolved.limit(name, { count: options.count, key: options.key?.(ctx) });
|
|
18
|
-
} catch (error) {
|
|
19
|
-
if (isLunoraError(error) && isInternalCode(error.code)) {
|
|
20
|
-
throw error;
|
|
21
|
-
}
|
|
22
|
-
console.error(`@lunora/ratelimit: rateLimit("${name}") threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
|
|
23
|
-
if (options.failOpen) {
|
|
24
|
-
return next();
|
|
25
|
-
}
|
|
26
|
-
throw new LunoraError("SERVICE_UNAVAILABLE", `rate limiter unavailable for "${name}"`, { cause: error, status: 503 });
|
|
27
|
-
}
|
|
28
|
-
if (!status.ok) {
|
|
29
|
-
const reason = status.reason ?? "rate";
|
|
30
|
-
const mapped = STATUS_BY_REASON[reason];
|
|
31
|
-
const retryAfter = Number.isFinite(status.retryAfter) ? Math.ceil(status.retryAfter) : void 0;
|
|
32
|
-
throw new LunoraError(mapped.code, options.message ?? defaultMessage(name, reason, retryAfter), {
|
|
33
|
-
status: mapped.status,
|
|
34
|
-
data: retryAfter === void 0 ? void 0 : { retryAfter }
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
return next();
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export { STATUS_BY_REASON, rateLimit };
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
const ratelimitPlugin = (limiter) => {
|
|
2
|
-
return {
|
|
3
|
-
key: "ratelimit",
|
|
4
|
-
middleware: async ({ ctx, next }) => {
|
|
5
|
-
const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
|
|
6
|
-
const existingApi = ctx.api ?? {};
|
|
7
|
-
return next({ ctx: { api: { ...existingApi, ratelimit: resolved } } });
|
|
8
|
-
}
|
|
9
|
-
};
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export { ratelimitPlugin };
|