@lunora/ratelimit 1.0.0-alpha.3 → 1.0.0-alpha.30
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/LICENSE.md +6 -0
- package/README.md +34 -16
- package/dist/index.d.mts +354 -8
- package/dist/index.d.ts +354 -8
- package/dist/index.mjs +1 -11
- package/dist/packem_shared/RateLimitError-BfvwU1oc.mjs +1 -0
- package/dist/packem_shared/RateLimiter-o7Qo4Iqd.mjs +1 -0
- package/dist/packem_shared/availableAt-DR8rnuHe.mjs +1 -0
- package/dist/packem_shared/createDbStore-ve7GsO2i.mjs +1 -0
- package/dist/packem_shared/dbRateLimit-OKevnT49.mjs +1 -0
- package/dist/packem_shared/rateLimit-DXZ0uxTf.mjs +1 -0
- package/dist/packem_shared/ratelimitPlugin-CFnDs9rj.mjs +1 -0
- package/dist/packem_shared/tokenBudget-DbgcKjcB.mjs +1 -0
- package/package.json +8 -1
- package/dist/packem_shared/RateLimitError-YOUfRzXc.mjs +0 -18
- package/dist/packem_shared/RateLimiter-CeElVjB0.mjs +0 -122
- package/dist/packem_shared/availableAt-D5GyJtxT.mjs +0 -117
- package/dist/packem_shared/createDbStore-L1kD1g1n.mjs +0 -103
- package/dist/packem_shared/dbRateLimit-C4nMF5cN.mjs +0 -7
- package/dist/packem_shared/rateLimit-uQxVMZfh.mjs +0 -42
- package/dist/packem_shared/ratelimitPlugin-D5_-KV1T.mjs +0 -12
package/LICENSE.md
CHANGED
|
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
|
|
|
103
103
|
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
104
|
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
105
|
specific language governing permissions and limitations under the License.
|
|
106
|
+
|
|
107
|
+
<!-- DEPENDENCIES -->
|
|
108
|
+
<!-- /DEPENDENCIES -->
|
|
109
|
+
|
|
110
|
+
<!-- TYPE_DEPENDENCIES -->
|
|
111
|
+
<!-- /TYPE_DEPENDENCIES -->
|
package/README.md
CHANGED
|
@@ -59,37 +59,55 @@ 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
|
-
export const send = mutation.use(
|
|
79
|
+
export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId })).mutation(async ({ ctx }) => {
|
|
75
80
|
// …
|
|
76
81
|
});
|
|
77
82
|
```
|
|
78
83
|
|
|
79
|
-
Or call the limiter directly:
|
|
84
|
+
Or call the limiter directly, inside a mutation/action so `ctx.db` is available:
|
|
80
85
|
|
|
81
86
|
```ts
|
|
82
|
-
|
|
87
|
+
import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
|
|
88
|
+
|
|
89
|
+
export const login = mutation.mutation(async ({ ctx, args }) => {
|
|
90
|
+
const limiter = new RateLimiter({ config, store: createDbStore({ db: ctx.db }) });
|
|
91
|
+
const status = await limiter.limit("login", { key: args.email });
|
|
83
92
|
|
|
84
|
-
if (!status.ok) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
if (!status.ok) {
|
|
94
|
+
// Stop here — `limit` returns a failing status by default rather than
|
|
95
|
+
// throwing, so falling through authenticates the rejected attempt.
|
|
96
|
+
// status.retryAfter is milliseconds until the request would succeed.
|
|
97
|
+
// status.reason is "rate" or "deny".
|
|
98
|
+
throw new RateLimitError(status);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await authenticate(args);
|
|
102
|
+
});
|
|
88
103
|
```
|
|
89
104
|
|
|
90
105
|
For durable per-DO state inside a procedure, back the limiter with a Lunora
|
|
91
106
|
table via `dbRateLimit(config, name, options)`, or supply a `store`
|
|
92
|
-
(`
|
|
107
|
+
(`createSqlStore` / `createDbStore`). `createMemoryStore` stays the default —
|
|
108
|
+
correct inside a single Durable Object — but constructing a `RateLimiter` with
|
|
109
|
+
no explicit `store` now warns once so that choice is visible instead of
|
|
110
|
+
silent.
|
|
93
111
|
|
|
94
112
|
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
|
|
95
113
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,60 +1,158 @@
|
|
|
1
1
|
import { Middleware, Plugin } from '@lunora/server';
|
|
2
2
|
import { Id } from '@lunora/values';
|
|
3
|
+
import { LunoraError } from '@lunora/errors';
|
|
4
|
+
/** Rate-limit algorithm. */
|
|
3
5
|
type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
|
|
6
|
+
/** Why a request was denied. */
|
|
4
7
|
type RateLimitReason = "deny" | "rate";
|
|
8
|
+
/** Definition of a single named rate limit. */
|
|
5
9
|
interface RateLimitConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Maximum tokens that can accumulate (the rollover ceiling). Defaults to
|
|
12
|
+
* `rate`: for a token bucket that caps a burst at one period's worth of
|
|
13
|
+
* tokens, and for a fixed window it disables cross-window rollover. Ignored
|
|
14
|
+
* by sliding windows, which always cap at `rate` per `period`.
|
|
15
|
+
*/
|
|
6
16
|
capacity?: number;
|
|
7
17
|
kind: RateLimitKind;
|
|
18
|
+
/** Window/refill period in milliseconds. */
|
|
8
19
|
period: number;
|
|
20
|
+
/** Tokens granted per `period`. */
|
|
9
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
|
+
*/
|
|
10
33
|
shards?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Phase offset in epoch milliseconds for windowed algorithms — windows
|
|
36
|
+
* align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
|
|
37
|
+
*/
|
|
11
38
|
start?: number;
|
|
12
39
|
}
|
|
40
|
+
/** A map of limit name to its config, used to construct a `RateLimiter`. */
|
|
13
41
|
type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
|
|
42
|
+
/** Persisted accounting state for one `(name, key)` pair. */
|
|
14
43
|
interface RateLimitValue {
|
|
44
|
+
/**
|
|
45
|
+
* Sliding window only: request count from the previous window, used to
|
|
46
|
+
* weight the current estimate. Unset for token-bucket / fixed-window.
|
|
47
|
+
*/
|
|
15
48
|
prev?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Token-bucket: timestamp of the last refill. Fixed/sliding window: start of
|
|
51
|
+
* the window the value belongs to.
|
|
52
|
+
*/
|
|
16
53
|
ts: number;
|
|
54
|
+
/**
|
|
55
|
+
* Tokens available (token bucket), tokens remaining in the window (fixed
|
|
56
|
+
* window), or requests made in the current window (sliding window).
|
|
57
|
+
* Fractional for token buckets; negative when reserved ahead.
|
|
58
|
+
*/
|
|
17
59
|
value: number;
|
|
18
60
|
}
|
|
61
|
+
/** Outcome of a `RateLimiter.limit` / `RateLimiter.check` call. */
|
|
19
62
|
interface RateLimitStatus {
|
|
63
|
+
/** Whether the request is permitted. */
|
|
20
64
|
ok: boolean;
|
|
65
|
+
/** Why the request was denied. Absent when `ok`. */
|
|
21
66
|
reason?: RateLimitReason;
|
|
67
|
+
/** Milliseconds until the request would succeed. `0` when `ok` without reservation. */
|
|
22
68
|
retryAfter: number;
|
|
23
69
|
}
|
|
70
|
+
/** Per-call options for `RateLimiter.limit`. */
|
|
24
71
|
interface RateLimitArgs {
|
|
72
|
+
/** Units to consume. Defaults to `1`. */
|
|
25
73
|
count?: number;
|
|
74
|
+
/** Sub-key isolating the limit (per user/team/IP). Omit for a global limit. */
|
|
26
75
|
key?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Permit the request even when capacity is insufficient, reserving future
|
|
78
|
+
* capacity (the stored value goes negative). `retryAfter` then reports when
|
|
79
|
+
* the debt clears. Rejected only when `count` exceeds the bucket capacity.
|
|
80
|
+
*/
|
|
27
81
|
reserve?: boolean;
|
|
82
|
+
/** Throw `RateLimitError` instead of returning a failing status. */
|
|
28
83
|
throws?: boolean;
|
|
29
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Pluggable persistence. Reads and writes are keyed by an opaque storage key
|
|
87
|
+
* the limiter derives from the limit name and `key`. Implementations may be
|
|
88
|
+
* synchronous (in-memory) or asynchronous (SQLite/KV); the limiter awaits
|
|
89
|
+
* either.
|
|
90
|
+
*/
|
|
30
91
|
interface RateLimitStore {
|
|
31
92
|
delete: (storageKey: string) => Promise<void> | void;
|
|
32
93
|
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
33
94
|
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
34
95
|
}
|
|
96
|
+
/** Inputs to {@link evaluate}. */
|
|
35
97
|
interface EvaluateOptions {
|
|
98
|
+
/** When `false`, compute status without consuming (a `check`). */
|
|
36
99
|
consume: boolean;
|
|
100
|
+
/** Units requested. */
|
|
37
101
|
count: number;
|
|
102
|
+
/** Current time in epoch milliseconds. */
|
|
38
103
|
now: number;
|
|
104
|
+
/** Permit a deficit by reserving future capacity (token bucket / within-window). */
|
|
39
105
|
reserve: boolean;
|
|
40
106
|
}
|
|
107
|
+
/** Result of evaluating a limit against its prior state. */
|
|
41
108
|
interface EvaluateResult {
|
|
42
109
|
status: RateLimitStatus;
|
|
110
|
+
/** Next value to persist, or `undefined` when the call must not mutate state. */
|
|
43
111
|
value: RateLimitValue | undefined;
|
|
44
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Project a limit's stored state forward to `now` without consuming: how many
|
|
115
|
+
* units could be admitted right now. Token bucket → the refilled token count;
|
|
116
|
+
* fixed window → tokens left in the current (possibly rolled-over) window;
|
|
117
|
+
* sliding window → `rate` minus the weighted estimate, floored at zero. Pure,
|
|
118
|
+
* like {@link evaluate}, and shares its per-algorithm projection helpers so the
|
|
119
|
+
* two never diverge. Backs `RateLimiter.getValue` so it reports a live figure
|
|
120
|
+
* rather than the last value that happened to be persisted.
|
|
121
|
+
*/
|
|
45
122
|
declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
|
|
46
123
|
ts: number;
|
|
47
124
|
value: number;
|
|
48
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* Evaluate a request against a limit's prior state. Pure: it never reads a
|
|
128
|
+
* clock or persists — the caller supplies `now` and writes back `value` when
|
|
129
|
+
* it is not `undefined`.
|
|
130
|
+
*/
|
|
49
131
|
declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
|
|
50
132
|
interface RateLimiterOptions<Names extends string> {
|
|
51
133
|
config: RateLimitConfigMap<Names>;
|
|
134
|
+
/** Keys that are always denied, regardless of limit state. */
|
|
52
135
|
denyList?: Iterable<string>;
|
|
136
|
+
/**
|
|
137
|
+
* Optional key normalizer applied to every incoming `args.key` (the
|
|
138
|
+
* deny-list check, the storage key, and downstream shard selection all see
|
|
139
|
+
* the normalized form). Use for case-folding, trimming, or canonicalizing
|
|
140
|
+
* IPs/emails so equivalent inputs share a single bucket. The deny-list
|
|
141
|
+
* itself is consulted as-is; normalize the deny-list entries up front to
|
|
142
|
+
* match.
|
|
143
|
+
*/
|
|
53
144
|
normalize?: (key: string) => string;
|
|
145
|
+
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
54
146
|
now?: () => number;
|
|
55
|
-
|
|
147
|
+
/** Persistence. Defaults to a per-instance in-memory store. */
|
|
56
148
|
store?: RateLimitStore;
|
|
57
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Enforces named rate limits over a pluggable store. Construct one per app with
|
|
152
|
+
* a config map; call {@link RateLimiter.limit} to consume and
|
|
153
|
+
* {@link RateLimiter.check} to peek. Framework-agnostic — the `@lunora/ratelimit`
|
|
154
|
+
* middleware wraps it for procedures.
|
|
155
|
+
*/
|
|
58
156
|
declare class RateLimiter<Names extends string = string> {
|
|
59
157
|
private readonly config;
|
|
60
158
|
private readonly denyList;
|
|
@@ -62,7 +160,16 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
62
160
|
private readonly now;
|
|
63
161
|
private readonly store;
|
|
64
162
|
constructor(options: RateLimiterOptions<Names>);
|
|
163
|
+
/** Peek at whether a request would be permitted without consuming. */
|
|
65
164
|
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
165
|
+
/**
|
|
166
|
+
* Read the current config and the units admittable right now for a
|
|
167
|
+
* `(name, key)` pair. The value is projected forward to the current clock
|
|
168
|
+
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
169
|
+
* the last persisted figure. For a sharded limit it reads only the single
|
|
170
|
+
* shard `limit()`/`run()` would route this key to — the sibling shards are
|
|
171
|
+
* never touched by this key, so summing them would over-report.
|
|
172
|
+
*/
|
|
66
173
|
getValue(name: Names, args?: {
|
|
67
174
|
key?: string;
|
|
68
175
|
}): Promise<{
|
|
@@ -70,22 +177,66 @@ declare class RateLimiter<Names extends string = string> {
|
|
|
70
177
|
ts: number;
|
|
71
178
|
value: number;
|
|
72
179
|
}>;
|
|
180
|
+
/** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
|
|
73
181
|
limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
|
|
182
|
+
/** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
|
|
74
183
|
reset(name: Names, args?: {
|
|
75
184
|
key?: string;
|
|
76
185
|
}): Promise<void>;
|
|
186
|
+
private normalizeKey;
|
|
77
187
|
private resolve;
|
|
78
188
|
private run;
|
|
79
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
|
|
192
|
+
* — the latter lets a procedure bind a durable, ORM-backed limiter at call time
|
|
193
|
+
* (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
|
|
194
|
+
*/
|
|
80
195
|
type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
|
|
81
196
|
interface RateLimitMiddlewareOptions<Context> {
|
|
197
|
+
/** Units to consume per call. Defaults to `1`. */
|
|
82
198
|
count?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Behavior when the limiter itself throws (store unavailable, etc).
|
|
201
|
+
* Defaults to `false` (fail closed: deny the request with a 503). Set to
|
|
202
|
+
* `true` only when degraded availability is preferable to denying traffic
|
|
203
|
+
* — note that a failing limiter then permits every request through.
|
|
204
|
+
*/
|
|
83
205
|
failOpen?: boolean;
|
|
206
|
+
/** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
|
|
84
207
|
key?: (context: Context) => string | undefined;
|
|
208
|
+
/** Override the error message thrown on rejection. */
|
|
85
209
|
message?: string;
|
|
86
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Procedure middleware that enforces a named rate limit before the handler
|
|
213
|
+
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
214
|
+
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
215
|
+
* hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
|
|
216
|
+
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
217
|
+
*
|
|
218
|
+
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
219
|
+
* availability reason (e.g. the persistence store is unavailable), the
|
|
220
|
+
* middleware **fails closed by default**: it logs via `console.error` and
|
|
221
|
+
* rejects the request with `503`. This is the safer default for
|
|
222
|
+
* security-sensitive limits (auth, account creation). Pass `failOpen: true` to
|
|
223
|
+
* swallow the error and admit the request instead — appropriate only when
|
|
224
|
+
* degraded availability is preferable to refusal. Deterministic caller misuse
|
|
225
|
+
* (an unconfigured limit name, a non-positive count, or a count that exceeds
|
|
226
|
+
* capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
|
|
227
|
+
* **both** policies — a config bug is never masked as a 503 or silently admitted.
|
|
228
|
+
*/
|
|
87
229
|
declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
|
|
230
|
+
/**
|
|
231
|
+
* In-memory store. State lives for the lifetime of the process (or, inside a
|
|
232
|
+
* Durable Object, the instance) — adequate for single-DO limits but not shared
|
|
233
|
+
* across instances. Use {@link createSqlStore} for durable per-DO state.
|
|
234
|
+
*/
|
|
88
235
|
declare const createMemoryStore: () => RateLimitStore;
|
|
236
|
+
/**
|
|
237
|
+
* Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
|
|
238
|
+
* satisfied by `node:sqlite`). Only the `exec` overload is required.
|
|
239
|
+
*/
|
|
89
240
|
interface SqlLike {
|
|
90
241
|
exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
|
|
91
242
|
toArray: () => Row[];
|
|
@@ -93,9 +244,29 @@ interface SqlLike {
|
|
|
93
244
|
}
|
|
94
245
|
interface SqlStoreOptions {
|
|
95
246
|
sql: SqlLike;
|
|
247
|
+
/** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
|
|
96
248
|
table?: string;
|
|
97
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* SQLite-backed store for durable, per-DO rate-limit state. Persists each
|
|
252
|
+
* `(name, key)` pair as one row so limits survive hibernation and eviction.
|
|
253
|
+
*
|
|
254
|
+
* **Atomicity:** the store does not wrap individual operations in an explicit
|
|
255
|
+
* SQL transaction. Inside a Durable Object the DO's input gate serializes
|
|
256
|
+
* every RPC call against the storage, so the limiter's read-modify-write
|
|
257
|
+
* sequence runs to completion without interleaving — this is the same
|
|
258
|
+
* guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
|
|
259
|
+
* (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
|
|
260
|
+
* tests or a custom host) the caller is responsible for serialization; the
|
|
261
|
+
* SQL surface used here (`exec`) is not a substitute for transactional
|
|
262
|
+
* isolation across concurrent invocations.
|
|
263
|
+
*/
|
|
98
264
|
declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
|
|
265
|
+
/**
|
|
266
|
+
* The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
|
|
267
|
+
* `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
|
|
268
|
+
* assignable; only `eq` is exercised.
|
|
269
|
+
*/
|
|
99
270
|
interface RateLimitDatabaseIndexRange {
|
|
100
271
|
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
101
272
|
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
@@ -103,34 +274,134 @@ interface RateLimitDatabaseIndexRange {
|
|
|
103
274
|
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
104
275
|
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
105
276
|
}
|
|
277
|
+
/** The slice of a `ctx.db` table query the store relies on. */
|
|
106
278
|
interface RateLimitDatabaseQuery {
|
|
107
279
|
first: () => Promise<Record<string, unknown> | null>;
|
|
108
280
|
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
109
281
|
}
|
|
110
|
-
|
|
282
|
+
/**
|
|
283
|
+
* The READ slice — everything the store needs to answer `RateLimiter.getValue`
|
|
284
|
+
* and `check`, which the docs describe as projecting the stored value forward to
|
|
285
|
+
* the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
|
|
286
|
+
*
|
|
287
|
+
* Split out because requiring the writer for a pure read meant "how many
|
|
288
|
+
* requests does this user have left" could not be answered from a query context
|
|
289
|
+
* at all — every remaining-quota display had to cast, and a cast that appears
|
|
290
|
+
* often enough stops carrying information. The distinction was already in the
|
|
291
|
+
* methods: `getValue`/`check` read, `limit`/`reset` write.
|
|
292
|
+
*/
|
|
293
|
+
interface RateLimitDatabaseReader {
|
|
294
|
+
query: (table: string) => RateLimitDatabaseQuery;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
|
|
298
|
+
* needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
|
|
299
|
+
* directly — declared here (rather than imported) to keep `@lunora/ratelimit`
|
|
300
|
+
* free of a runtime dependency on `@lunora/server`.
|
|
301
|
+
*/
|
|
302
|
+
interface RateLimitDatabase extends RateLimitDatabaseReader {
|
|
111
303
|
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
112
304
|
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
113
305
|
patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
|
|
114
|
-
query: (table: string) => RateLimitDatabaseQuery;
|
|
115
306
|
}
|
|
116
|
-
|
|
117
|
-
|
|
307
|
+
/** The table/column/index knobs shared by the read-only and read-write stores. */
|
|
308
|
+
interface DatabaseStoreLocation {
|
|
309
|
+
/** Index that resolves a row by its key column. Defaults to `by_key`. */
|
|
118
310
|
index?: string;
|
|
311
|
+
/** Column storing the opaque key. Defaults to `key`. */
|
|
119
312
|
keyField?: string;
|
|
313
|
+
/** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
|
|
120
314
|
table?: string;
|
|
121
315
|
}
|
|
316
|
+
interface DatabaseStoreOptions extends DatabaseStoreLocation {
|
|
317
|
+
/** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
|
|
318
|
+
db: RateLimitDatabase;
|
|
319
|
+
}
|
|
320
|
+
interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
321
|
+
/** The Lunora ORM reader — `ctx.db` inside a query. */
|
|
322
|
+
db: RateLimitDatabaseReader;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
|
|
326
|
+
* inside a procedure (the procedure context exposes no raw SQL). Declare a
|
|
327
|
+
* table with the key column and its index, e.g.
|
|
328
|
+
*
|
|
329
|
+
* ```ts
|
|
330
|
+
* rateLimits: defineTable({
|
|
331
|
+
* key: v.string(),
|
|
332
|
+
* ts: v.number(),
|
|
333
|
+
* value: v.number(),
|
|
334
|
+
* prev: v.optional(v.number()),
|
|
335
|
+
* }).index("by_key", ["key"])
|
|
336
|
+
* ```
|
|
337
|
+
*
|
|
338
|
+
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
339
|
+
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
340
|
+
*/
|
|
122
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;
|
|
356
|
+
/**
|
|
357
|
+
* DB-backed rate-limit middleware sugar. Collapses the common
|
|
358
|
+
*
|
|
359
|
+
* ```ts
|
|
360
|
+
* rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
|
|
361
|
+
* ```
|
|
362
|
+
*
|
|
363
|
+
* into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
|
|
364
|
+
* whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
|
|
365
|
+
* on the DO the procedure runs on). Every query/mutation/action ctx exposes a
|
|
366
|
+
* compatible `db`, so it slots straight into a `.use(...)` chain.
|
|
367
|
+
*
|
|
368
|
+
* Pass `options.store` to point at a non-default backing table/index/key column
|
|
369
|
+
* (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
|
|
370
|
+
* `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
|
|
371
|
+
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
372
|
+
* autocompletes to its declared limit names.
|
|
373
|
+
*
|
|
374
|
+
* Re-exported as `dbRateLimit` from the package root.
|
|
375
|
+
*
|
|
376
|
+
* ```ts
|
|
377
|
+
* const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
|
|
378
|
+
*
|
|
379
|
+
* export const send = mutation
|
|
380
|
+
* .input({ text: v.string() })
|
|
381
|
+
* .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
|
|
382
|
+
* .mutation(async ({ ctx, args }) => ...);
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
123
385
|
declare const databaseRateLimit: <Context extends {
|
|
124
386
|
db: RateLimitDatabase;
|
|
125
387
|
}, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
|
|
126
388
|
store?: Omit<DatabaseStoreOptions, "db">;
|
|
127
389
|
}) => Middleware<Context, Context>;
|
|
128
|
-
|
|
129
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
|
|
392
|
+
* `LunoraError` subclass whose code/status track `status.reason`: a rate
|
|
393
|
+
* rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
|
|
394
|
+
* the same mapping the middleware applies, so both entry points surface the
|
|
395
|
+
* identical wire code (a permanent deny is never a retryable 429). The
|
|
396
|
+
* middleware itself throws a bare structural `LunoraError`, so this is for
|
|
397
|
+
* direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
|
|
398
|
+
*/
|
|
399
|
+
declare class RateLimitError extends LunoraError {
|
|
130
400
|
readonly reason: RateLimitReason | undefined;
|
|
131
401
|
readonly retryAfter: number;
|
|
132
402
|
constructor(status: RateLimitStatus, message?: string);
|
|
133
403
|
}
|
|
404
|
+
/** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
|
|
134
405
|
interface RatelimitApiContext<Context> {
|
|
135
406
|
api: (Context extends {
|
|
136
407
|
api: infer A;
|
|
@@ -138,6 +409,81 @@ interface RatelimitApiContext<Context> {
|
|
|
138
409
|
ratelimit: RateLimiter;
|
|
139
410
|
};
|
|
140
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
|
|
414
|
+
* form of the plugin contract: instead of (or alongside) the enforcing
|
|
415
|
+
* `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
|
|
416
|
+
* under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
|
|
417
|
+
* programmatically.
|
|
418
|
+
*
|
|
419
|
+
* Install the middleware with one `.use(...)` (or fold it in with
|
|
420
|
+
* `composePluginMiddleware([...])`):
|
|
421
|
+
*
|
|
422
|
+
* ```ts
|
|
423
|
+
* const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
|
|
424
|
+
* const c = initLunora.dataModel<DataModel>().create();
|
|
425
|
+
* export const send = c.mutation
|
|
426
|
+
* .use(ratelimitPlugin(limiter).middleware!)
|
|
427
|
+
* .mutation(async ({ ctx, args }) => {
|
|
428
|
+
* const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
|
|
429
|
+
* if (!status.ok) throw new Error("slow down");
|
|
430
|
+
* // …
|
|
431
|
+
* });
|
|
432
|
+
* ```
|
|
433
|
+
*
|
|
434
|
+
* The plugin ships no schema extension — the limiter's persistence is whatever
|
|
435
|
+
* store the resolved {@link RateLimiter} was built with — so it is a
|
|
436
|
+
* middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
|
|
437
|
+
*
|
|
438
|
+
* Built as a plain {@link Plugin} literal (the key is the fixed string
|
|
439
|
+
* `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
|
|
440
|
+
* `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
|
|
441
|
+
*/
|
|
141
442
|
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
443
|
+
/** A budget bound to one named limit — check before the call, record after it. */
|
|
444
|
+
interface TokenBudget {
|
|
445
|
+
/**
|
|
446
|
+
* Peek at the budget before spending. `ok: false` means it is exhausted:
|
|
447
|
+
* refuse the call, and `retryAfter` says when it refills. Consumes nothing.
|
|
448
|
+
*/
|
|
449
|
+
check: (key: string) => Promise<RateLimitStatus>;
|
|
450
|
+
/**
|
|
451
|
+
* Charge the tokens a call actually used. Always call it, including when the
|
|
452
|
+
* call THREW — a failed generation that consumed input tokens still has to be
|
|
453
|
+
* paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
|
|
454
|
+
* nothing.
|
|
455
|
+
*
|
|
456
|
+
* The charge is a reservation, so it may take the bucket negative: the tokens
|
|
457
|
+
* are already spent, and refusing to record them would let a single oversized
|
|
458
|
+
* call escape the budget entirely.
|
|
459
|
+
*/
|
|
460
|
+
record: (key: string, tokens: number) => Promise<RateLimitStatus>;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Bind a {@link TokenBudget} to one of a limiter's named limits.
|
|
464
|
+
*
|
|
465
|
+
* ```ts
|
|
466
|
+
* const budget = tokenBudget(limiter, "tokens");
|
|
467
|
+
* const allowed = await budget.check(userId);
|
|
468
|
+
*
|
|
469
|
+
* if (!allowed.ok) {
|
|
470
|
+
* throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
|
|
471
|
+
* }
|
|
472
|
+
*
|
|
473
|
+
* try {
|
|
474
|
+
* const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
|
|
475
|
+
*
|
|
476
|
+
* await budget.record(userId, usage?.totalTokens ?? 0);
|
|
477
|
+
*
|
|
478
|
+
* return text;
|
|
479
|
+
* } catch (error) {
|
|
480
|
+
* // The prompt was still sent — charge what is known, then rethrow.
|
|
481
|
+
* await budget.record(userId, estimatedInputTokens);
|
|
482
|
+
*
|
|
483
|
+
* throw error;
|
|
484
|
+
* }
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
|
|
142
488
|
declare const VERSION = "0.0.0";
|
|
143
|
-
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 };
|
|
489
|
+
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 };
|