@lunora/ratelimit 1.0.0-alpha.9 → 1.0.0-alpha.90

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 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, sharding, pluggable stores, and procedure middleware</h3>
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, optional sharding for hot limits, pluggable stores (in-memory, SQL, or the Lunora ORM), and procedure middleware that rides the `.use()` chain.
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,63 @@ 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 { RateLimiter, rateLimit } from "@lunora/ratelimit";
62
+ import { dbRateLimit } from "@lunora/ratelimit";
63
63
 
64
64
  import { mutation } from "./_generated/server";
65
65
 
66
- const limiter = new RateLimiter({
67
- config: {
68
- login: { kind: "fixed window", period: 60_000, rate: 5 },
69
- send: { kind: "token bucket", period: 1_000, rate: 10 },
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
- export const send = mutation.use(rateLimit(limiter, "send", { key: (ctx) => ctx.auth.userId })).mutation(async ({ ctx }) => {
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
- const status = await limiter.limit("send", { key: userId });
93
+ import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
94
+
95
+ import { action } from "./_generated/server";
83
96
 
84
- if (!status.ok) {
85
- // status.retryAfter is milliseconds until the request would succeed.
86
- // status.reason is "rate" or "deny".
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
- (`createMemoryStore` / `createSqlStore` / `createDbStore`).
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.
93
119
 
94
120
  > This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
95
121
 
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,8 +123,7 @@ 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, the storage key, and downstream shard selection all see
139
- * the normalized form). Use for case-folding, trimming, or canonicalizing
126
+ * deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
140
127
  * IPs/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.
@@ -166,9 +153,7 @@ declare class RateLimiter<Names extends string = string> {
166
153
  * Read the current config and the units admittable right now for a
167
154
  * `(name, key)` pair. The value is projected forward to the current clock
168
155
  * (token-bucket refill, fixed-window rollover, sliding-window decay), not
169
- * the last persisted figure. For a sharded limit it reads only the single
170
- * shard `limit()`/`run()` would route this key to — the sibling shards are
171
- * never touched by this key, so summing them would over-report.
156
+ * the last persisted figure.
172
157
  */
173
158
  getValue(name: Names, args?: {
174
159
  key?: string;
@@ -183,6 +168,7 @@ declare class RateLimiter<Names extends string = string> {
183
168
  reset(name: Names, args?: {
184
169
  key?: string;
185
170
  }): Promise<void>;
171
+ private normalizeKey;
186
172
  private resolve;
187
173
  private run;
188
174
  }
@@ -202,7 +188,15 @@ interface RateLimitMiddlewareOptions<Context> {
202
188
  * — note that a failing limiter then permits every request through.
203
189
  */
204
190
  failOpen?: boolean;
205
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
191
+ /**
192
+ * Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
193
+ *
194
+ * A resolver that returns `undefined` is a config bug, not a global limit:
195
+ * the middleware throws `INTERNAL` rather than silently pooling every
196
+ * keyless caller (e.g. every anonymous user) into one shared bucket. Fold
197
+ * the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
198
+ * shared bucket is a visible choice.
199
+ */
206
200
  key?: (context: Context) => string | undefined;
207
201
  /** Override the error message thrown on rejection. */
208
202
  message?: string;
@@ -211,7 +205,7 @@ interface RateLimitMiddlewareOptions<Context> {
211
205
  * Procedure middleware that enforces a named rate limit before the handler
212
206
  * runs. Attach it with `.use()`. On rejection it throws a structural
213
207
  * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
- * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
208
+ * hits) carrying `data.retryAfterMs` — the runtime maps it to the
215
209
  * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
210
  *
217
211
  * **Failure policy:** if resolving or invoking the limiter throws for a genuine
@@ -278,21 +272,33 @@ interface RateLimitDatabaseQuery {
278
272
  first: () => Promise<Record<string, unknown> | null>;
279
273
  withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
280
274
  }
275
+ /**
276
+ * The READ slice — everything the store needs to answer `RateLimiter.getValue`
277
+ * and `check`, which the docs describe as projecting the stored value forward to
278
+ * the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
279
+ *
280
+ * Split out because requiring the writer for a pure read meant "how many
281
+ * requests does this user have left" could not be answered from a query context
282
+ * at all — every remaining-quota display had to cast, and a cast that appears
283
+ * often enough stops carrying information. The distinction was already in the
284
+ * methods: `getValue`/`check` read, `limit`/`reset` write.
285
+ */
286
+ interface RateLimitDatabaseReader {
287
+ query: (table: string) => RateLimitDatabaseQuery;
288
+ }
281
289
  /**
282
290
  * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
283
291
  * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
284
292
  * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
285
293
  * free of a runtime dependency on `@lunora/server`.
286
294
  */
287
- interface RateLimitDatabase {
295
+ interface RateLimitDatabase extends RateLimitDatabaseReader {
288
296
  delete: <T extends string>(id: Id<T>) => Promise<void>;
289
297
  insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
290
298
  patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
291
- query: (table: string) => RateLimitDatabaseQuery;
292
299
  }
293
- interface DatabaseStoreOptions {
294
- /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
295
- db: RateLimitDatabase;
300
+ /** The table/column/index knobs shared by the read-only and read-write stores. */
301
+ interface DatabaseStoreLocation {
296
302
  /** Index that resolves a row by its key column. Defaults to `by_key`. */
297
303
  index?: string;
298
304
  /** Column storing the opaque key. Defaults to `key`. */
@@ -300,6 +306,14 @@ interface DatabaseStoreOptions {
300
306
  /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
301
307
  table?: string;
302
308
  }
309
+ interface DatabaseStoreOptions extends DatabaseStoreLocation {
310
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
311
+ db: RateLimitDatabase;
312
+ }
313
+ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
314
+ /** The Lunora ORM reader — `ctx.db` inside a query. */
315
+ db: RateLimitDatabaseReader;
316
+ }
303
317
  /**
304
318
  * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
305
319
  * inside a procedure (the procedure context exposes no raw SQL). Declare a
@@ -316,8 +330,29 @@ interface DatabaseStoreOptions {
316
330
  *
317
331
  * Each operation is a read-then-write; inside a mutation/action that pair runs
318
332
  * under the DO's input gate, so it is atomic against concurrent calls.
333
+ *
334
+ * **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
335
+ * its storage transaction, so a handler that throws after `limit()` rolls the
336
+ * consumed unit back with everything else — inside a mutation this store counts
337
+ * successful calls, not attempts. To charge every attempt (a login limiter),
338
+ * consume from an action, where each write commits on its own, or return a
339
+ * failure value from the mutation instead of throwing.
319
340
  */
320
341
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
342
+ /**
343
+ * Read-only counterpart to {@link createDatabaseStore}, for a query context.
344
+ *
345
+ * `get` behaves identically — same table, index and key column — so
346
+ * `RateLimiter.getValue` / `check` report exactly what the writing store would.
347
+ * `set` and `delete` are the only difference: they throw rather than silently
348
+ * doing nothing, because a limiter that appears to consume budget and does not
349
+ * is worse than one that refuses.
350
+ *
351
+ * This mirrors the split Lunora already makes for `ctx.storage`, which is a
352
+ * `ReadOnlyStorage` in a query and a full `Storage` in an action — the
353
+ * capability difference is visible in the type instead of discovered at runtime.
354
+ */
355
+ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOptions) => RateLimitStore;
321
356
  /**
322
357
  * DB-backed rate-limit middleware sugar. Collapses the common
323
358
  *
@@ -336,6 +371,11 @@ declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitS
336
371
  * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
337
372
  * autocompletes to its declared limit names.
338
373
  *
374
+ * On a mutation the consumed unit commits with the handler: a handler that
375
+ * throws rolls it back, so a failed call costs nothing. Attach it to an action
376
+ * (whose writes commit independently) when failed attempts must count — see
377
+ * {@link createDatabaseStore}.
378
+ *
339
379
  * Re-exported as `dbRateLimit` from the package root.
340
380
  *
341
381
  * ```ts
@@ -386,7 +426,7 @@ interface RatelimitApiContext<Context> {
386
426
  *
387
427
  * ```ts
388
428
  * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
389
- * const c = initLunora.dataModel&lt;DataModel>().create();
429
+ * const c = initLunora.dataModel<DataModel>().create();
390
430
  * export const send = c.mutation
391
431
  * .use(ratelimitPlugin(limiter).middleware!)
392
432
  * .mutation(async ({ ctx, args }) => {
@@ -405,5 +445,50 @@ interface RatelimitApiContext<Context> {
405
445
  * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
406
446
  */
407
447
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
448
+ /** A budget bound to one named limit — check before the call, record after it. */
449
+ interface TokenBudget {
450
+ /**
451
+ * Peek at the budget before spending. `ok: false` means it is exhausted:
452
+ * refuse the call, and `retryAfter` says when it refills. Consumes nothing.
453
+ */
454
+ check: (key: string) => Promise<RateLimitStatus>;
455
+ /**
456
+ * Charge the tokens a call actually used. Always call it, including when the
457
+ * call THREW — a failed generation that consumed input tokens still has to be
458
+ * paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
459
+ * nothing.
460
+ *
461
+ * The charge is a reservation, so it may take the bucket negative: the tokens
462
+ * are already spent, and refusing to record them would let a single oversized
463
+ * call escape the budget entirely.
464
+ */
465
+ record: (key: string, tokens: number) => Promise<RateLimitStatus>;
466
+ }
467
+ /**
468
+ * Bind a {@link TokenBudget} to one of a limiter's named limits.
469
+ *
470
+ * ```ts
471
+ * const budget = tokenBudget(limiter, "tokens");
472
+ * const allowed = await budget.check(userId);
473
+ *
474
+ * if (!allowed.ok) {
475
+ * throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
476
+ * }
477
+ *
478
+ * try {
479
+ * const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
480
+ *
481
+ * await budget.record(userId, usage?.totalTokens ?? 0);
482
+ *
483
+ * return text;
484
+ * } catch (error) {
485
+ * // The prompt was still sent — charge what is known, then rethrow.
486
+ * await budget.record(userId, estimatedInputTokens);
487
+ *
488
+ * throw error;
489
+ * }
490
+ * ```
491
+ */
492
+ declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
408
493
  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 };
494
+ 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,8 +123,7 @@ 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, the storage key, and downstream shard selection all see
139
- * the normalized form). Use for case-folding, trimming, or canonicalizing
126
+ * deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
140
127
  * IPs/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.
@@ -166,9 +153,7 @@ declare class RateLimiter<Names extends string = string> {
166
153
  * Read the current config and the units admittable right now for a
167
154
  * `(name, key)` pair. The value is projected forward to the current clock
168
155
  * (token-bucket refill, fixed-window rollover, sliding-window decay), not
169
- * the last persisted figure. For a sharded limit it reads only the single
170
- * shard `limit()`/`run()` would route this key to — the sibling shards are
171
- * never touched by this key, so summing them would over-report.
156
+ * the last persisted figure.
172
157
  */
173
158
  getValue(name: Names, args?: {
174
159
  key?: string;
@@ -183,6 +168,7 @@ declare class RateLimiter<Names extends string = string> {
183
168
  reset(name: Names, args?: {
184
169
  key?: string;
185
170
  }): Promise<void>;
171
+ private normalizeKey;
186
172
  private resolve;
187
173
  private run;
188
174
  }
@@ -202,7 +188,15 @@ interface RateLimitMiddlewareOptions<Context> {
202
188
  * — note that a failing limiter then permits every request through.
203
189
  */
204
190
  failOpen?: boolean;
205
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
191
+ /**
192
+ * Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
193
+ *
194
+ * A resolver that returns `undefined` is a config bug, not a global limit:
195
+ * the middleware throws `INTERNAL` rather than silently pooling every
196
+ * keyless caller (e.g. every anonymous user) into one shared bucket. Fold
197
+ * the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
198
+ * shared bucket is a visible choice.
199
+ */
206
200
  key?: (context: Context) => string | undefined;
207
201
  /** Override the error message thrown on rejection. */
208
202
  message?: string;
@@ -211,7 +205,7 @@ interface RateLimitMiddlewareOptions<Context> {
211
205
  * Procedure middleware that enforces a named rate limit before the handler
212
206
  * runs. Attach it with `.use()`. On rejection it throws a structural
213
207
  * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
- * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
208
+ * hits) carrying `data.retryAfterMs` — the runtime maps it to the
215
209
  * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
210
  *
217
211
  * **Failure policy:** if resolving or invoking the limiter throws for a genuine
@@ -278,21 +272,33 @@ interface RateLimitDatabaseQuery {
278
272
  first: () => Promise<Record<string, unknown> | null>;
279
273
  withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
280
274
  }
275
+ /**
276
+ * The READ slice — everything the store needs to answer `RateLimiter.getValue`
277
+ * and `check`, which the docs describe as projecting the stored value forward to
278
+ * the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
279
+ *
280
+ * Split out because requiring the writer for a pure read meant "how many
281
+ * requests does this user have left" could not be answered from a query context
282
+ * at all — every remaining-quota display had to cast, and a cast that appears
283
+ * often enough stops carrying information. The distinction was already in the
284
+ * methods: `getValue`/`check` read, `limit`/`reset` write.
285
+ */
286
+ interface RateLimitDatabaseReader {
287
+ query: (table: string) => RateLimitDatabaseQuery;
288
+ }
281
289
  /**
282
290
  * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
283
291
  * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
284
292
  * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
285
293
  * free of a runtime dependency on `@lunora/server`.
286
294
  */
287
- interface RateLimitDatabase {
295
+ interface RateLimitDatabase extends RateLimitDatabaseReader {
288
296
  delete: <T extends string>(id: Id<T>) => Promise<void>;
289
297
  insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
290
298
  patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
291
- query: (table: string) => RateLimitDatabaseQuery;
292
299
  }
293
- interface DatabaseStoreOptions {
294
- /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
295
- db: RateLimitDatabase;
300
+ /** The table/column/index knobs shared by the read-only and read-write stores. */
301
+ interface DatabaseStoreLocation {
296
302
  /** Index that resolves a row by its key column. Defaults to `by_key`. */
297
303
  index?: string;
298
304
  /** Column storing the opaque key. Defaults to `key`. */
@@ -300,6 +306,14 @@ interface DatabaseStoreOptions {
300
306
  /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
301
307
  table?: string;
302
308
  }
309
+ interface DatabaseStoreOptions extends DatabaseStoreLocation {
310
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
311
+ db: RateLimitDatabase;
312
+ }
313
+ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
314
+ /** The Lunora ORM reader — `ctx.db` inside a query. */
315
+ db: RateLimitDatabaseReader;
316
+ }
303
317
  /**
304
318
  * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
305
319
  * inside a procedure (the procedure context exposes no raw SQL). Declare a
@@ -316,8 +330,29 @@ interface DatabaseStoreOptions {
316
330
  *
317
331
  * Each operation is a read-then-write; inside a mutation/action that pair runs
318
332
  * under the DO's input gate, so it is atomic against concurrent calls.
333
+ *
334
+ * **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
335
+ * its storage transaction, so a handler that throws after `limit()` rolls the
336
+ * consumed unit back with everything else — inside a mutation this store counts
337
+ * successful calls, not attempts. To charge every attempt (a login limiter),
338
+ * consume from an action, where each write commits on its own, or return a
339
+ * failure value from the mutation instead of throwing.
319
340
  */
320
341
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
342
+ /**
343
+ * Read-only counterpart to {@link createDatabaseStore}, for a query context.
344
+ *
345
+ * `get` behaves identically — same table, index and key column — so
346
+ * `RateLimiter.getValue` / `check` report exactly what the writing store would.
347
+ * `set` and `delete` are the only difference: they throw rather than silently
348
+ * doing nothing, because a limiter that appears to consume budget and does not
349
+ * is worse than one that refuses.
350
+ *
351
+ * This mirrors the split Lunora already makes for `ctx.storage`, which is a
352
+ * `ReadOnlyStorage` in a query and a full `Storage` in an action — the
353
+ * capability difference is visible in the type instead of discovered at runtime.
354
+ */
355
+ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOptions) => RateLimitStore;
321
356
  /**
322
357
  * DB-backed rate-limit middleware sugar. Collapses the common
323
358
  *
@@ -336,6 +371,11 @@ declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitS
336
371
  * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
337
372
  * autocompletes to its declared limit names.
338
373
  *
374
+ * On a mutation the consumed unit commits with the handler: a handler that
375
+ * throws rolls it back, so a failed call costs nothing. Attach it to an action
376
+ * (whose writes commit independently) when failed attempts must count — see
377
+ * {@link createDatabaseStore}.
378
+ *
339
379
  * Re-exported as `dbRateLimit` from the package root.
340
380
  *
341
381
  * ```ts
@@ -386,7 +426,7 @@ interface RatelimitApiContext<Context> {
386
426
  *
387
427
  * ```ts
388
428
  * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
389
- * const c = initLunora.dataModel&lt;DataModel>().create();
429
+ * const c = initLunora.dataModel<DataModel>().create();
390
430
  * export const send = c.mutation
391
431
  * .use(ratelimitPlugin(limiter).middleware!)
392
432
  * .mutation(async ({ ctx, args }) => {
@@ -405,5 +445,50 @@ interface RatelimitApiContext<Context> {
405
445
  * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
406
446
  */
407
447
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
448
+ /** A budget bound to one named limit — check before the call, record after it. */
449
+ interface TokenBudget {
450
+ /**
451
+ * Peek at the budget before spending. `ok: false` means it is exhausted:
452
+ * refuse the call, and `retryAfter` says when it refills. Consumes nothing.
453
+ */
454
+ check: (key: string) => Promise<RateLimitStatus>;
455
+ /**
456
+ * Charge the tokens a call actually used. Always call it, including when the
457
+ * call THREW — a failed generation that consumed input tokens still has to be
458
+ * paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
459
+ * nothing.
460
+ *
461
+ * The charge is a reservation, so it may take the bucket negative: the tokens
462
+ * are already spent, and refusing to record them would let a single oversized
463
+ * call escape the budget entirely.
464
+ */
465
+ record: (key: string, tokens: number) => Promise<RateLimitStatus>;
466
+ }
467
+ /**
468
+ * Bind a {@link TokenBudget} to one of a limiter's named limits.
469
+ *
470
+ * ```ts
471
+ * const budget = tokenBudget(limiter, "tokens");
472
+ * const allowed = await budget.check(userId);
473
+ *
474
+ * if (!allowed.ok) {
475
+ * throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
476
+ * }
477
+ *
478
+ * try {
479
+ * const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
480
+ *
481
+ * await budget.record(userId, usage?.totalTokens ?? 0);
482
+ *
483
+ * return text;
484
+ * } catch (error) {
485
+ * // The prompt was still sent — charge what is known, then rethrow.
486
+ * await budget.record(userId, estimatedInputTokens);
487
+ *
488
+ * throw error;
489
+ * }
490
+ * ```
491
+ */
492
+ declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
408
493
  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 };
494
+ 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
- export { availableAt, evaluate } from './packem_shared/availableAt-DQcuVfSA.mjs';
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-rlAL4c2M.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-DKXPLL9z.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 o}from"@lunora/errors";import{availableAt as w,evaluate as y}from"./availableAt-BSKYrouo.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as v}from"./createDbStore-ve7GsO2i.mjs";const l=(n,e)=>e===void 0?encodeURIComponent(n):`${encodeURIComponent(n)}:${encodeURIComponent(e)}`,p="@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 L{config;denyList;normalize;now;store;constructor(e){this.config=e.config,this.normalize=e.normalize??(t=>t),this.denyList=new Set([...e.denyList??[]].flatMap(t=>[t,this.normalize(t)])),this.now=e.now??Date.now,e.store===void 0&&console.warn(p),this.store=e.store??v();for(const[t,r]of Object.entries(this.config)){if(Object.hasOwn(r,"shards"))throw new o("INTERNAL",`rate limit "${t}": \`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 o("INTERNAL",`rate limit "${t}": period must be a positive number`);if(!Number.isFinite(r.rate)||r.rate<=0)throw new o("INTERNAL",`rate limit "${t}": rate must be a positive number`);if(r.capacity!==void 0&&(!Number.isFinite(r.capacity)||r.capacity<0))throw new o("INTERNAL",`rate limit "${t}": capacity must be a non-negative number`)}}async check(e,t={}){return this.run(e,t,!1)}async getValue(e,t={}){const r=this.resolve(e),a=this.now(),s=l(e,this.normalizeKey(t.key)),i=w(r,await this.store.get(s),a);return{config:r,ts:i.ts,value:i.value}}async limit(e,t={}){return this.run(e,t,!0)}async reset(e,t={}){this.resolve(e),await Promise.resolve(this.store.delete(l(e,this.normalizeKey(t.key))))}normalizeKey(e){return e===void 0?void 0:this.normalize(e)}resolve(e){const t=this.config[e];if(!t)throw new o("INTERNAL",`rate limit "${e}" is not configured`);return t}async run(e,t,r){const a=this.resolve(e),s=this.normalizeKey(t.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(t.key))){const u={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(t.throws)throw new f(u);return u}const i=t.count??1;if(!Number.isInteger(i)||i<=0)throw new o("INTERNAL",`rate limit "${e}": count must be a positive integer`);const h=l(e,s),d=await this.store.get(h),{status:c,value:m}=y(a,d,{consume:r,count:i,now:this.now(),reserve:t.reserve??!1});if(m!==void 0&&await this.store.set(h,m),!c.ok&&t.throws)throw new f(c);return c}}export{L 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-DKXPLL9z.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.9",
4
- "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
3
+ "version": "1.0.0-alpha.90",
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.6"
49
+ "@lunora/errors": "1.0.0-alpha.46"
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 };