@lunora/ratelimit 0.0.0 → 1.0.0-alpha.10

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.
@@ -0,0 +1,409 @@
1
+ import { Middleware, Plugin } from '@lunora/server';
2
+ import { Id } from '@lunora/values';
3
+ import { LunoraError } from '@lunora/errors';
4
+ /** Rate-limit algorithm. */
5
+ type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
6
+ /** Why a request was denied. */
7
+ type RateLimitReason = "deny" | "rate";
8
+ /** Definition of a single named rate limit. */
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
+ */
16
+ capacity?: number;
17
+ kind: RateLimitKind;
18
+ /** Window/refill period in milliseconds. */
19
+ period: number;
20
+ /** Tokens granted per `period`. */
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
+ /**
35
+ * Phase offset in epoch milliseconds for windowed algorithms — windows
36
+ * align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
37
+ */
38
+ start?: number;
39
+ }
40
+ /** A map of limit name to its config, used to construct a `RateLimiter`. */
41
+ type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
42
+ /** Persisted accounting state for one `(name, key)` pair. */
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
+ */
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
+ */
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
+ */
59
+ value: number;
60
+ }
61
+ /** Outcome of a `RateLimiter.limit` / `RateLimiter.check` call. */
62
+ interface RateLimitStatus {
63
+ /** Whether the request is permitted. */
64
+ ok: boolean;
65
+ /** Why the request was denied. Absent when `ok`. */
66
+ reason?: RateLimitReason;
67
+ /** Milliseconds until the request would succeed. `0` when `ok` without reservation. */
68
+ retryAfter: number;
69
+ }
70
+ /** Per-call options for `RateLimiter.limit`. */
71
+ interface RateLimitArgs {
72
+ /** Units to consume. Defaults to `1`. */
73
+ count?: number;
74
+ /** Sub-key isolating the limit (per user/team/IP). Omit for a global limit. */
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
+ */
81
+ reserve?: boolean;
82
+ /** Throw `RateLimitError` instead of returning a failing status. */
83
+ throws?: boolean;
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
+ */
91
+ interface RateLimitStore {
92
+ delete: (storageKey: string) => Promise<void> | void;
93
+ get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
94
+ set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
95
+ }
96
+ /** Inputs to {@link evaluate}. */
97
+ interface EvaluateOptions {
98
+ /** When `false`, compute status without consuming (a `check`). */
99
+ consume: boolean;
100
+ /** Units requested. */
101
+ count: number;
102
+ /** Current time in epoch milliseconds. */
103
+ now: number;
104
+ /** Permit a deficit by reserving future capacity (token bucket / within-window). */
105
+ reserve: boolean;
106
+ }
107
+ /** Result of evaluating a limit against its prior state. */
108
+ interface EvaluateResult {
109
+ status: RateLimitStatus;
110
+ /** Next value to persist, or `undefined` when the call must not mutate state. */
111
+ value: RateLimitValue | undefined;
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
+ */
122
+ declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
123
+ ts: number;
124
+ value: number;
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
+ */
131
+ declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
132
+ interface RateLimiterOptions<Names extends string> {
133
+ config: RateLimitConfigMap<Names>;
134
+ /** Keys that are always denied, regardless of limit state. */
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
+ */
144
+ normalize?: (key: string) => string;
145
+ /** Clock injection for tests. Defaults to `Date.now`. */
146
+ now?: () => number;
147
+ /** Persistence. Defaults to a per-instance in-memory store. */
148
+ store?: RateLimitStore;
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
+ */
156
+ declare class RateLimiter<Names extends string = string> {
157
+ private readonly config;
158
+ private readonly denyList;
159
+ private readonly normalize;
160
+ private readonly now;
161
+ private readonly store;
162
+ constructor(options: RateLimiterOptions<Names>);
163
+ /** Peek at whether a request would be permitted without consuming. */
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
+ */
173
+ getValue(name: Names, args?: {
174
+ key?: string;
175
+ }): Promise<{
176
+ config: RateLimitConfig;
177
+ ts: number;
178
+ value: number;
179
+ }>;
180
+ /** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
181
+ limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
182
+ /** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
183
+ reset(name: Names, args?: {
184
+ key?: string;
185
+ }): Promise<void>;
186
+ private resolve;
187
+ private run;
188
+ }
189
+ /**
190
+ * Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
191
+ * — the latter lets a procedure bind a durable, ORM-backed limiter at call time
192
+ * (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
193
+ */
194
+ type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
195
+ interface RateLimitMiddlewareOptions<Context> {
196
+ /** Units to consume per call. Defaults to `1`. */
197
+ count?: number;
198
+ /**
199
+ * Behavior when the limiter itself throws (store unavailable, etc).
200
+ * Defaults to `false` (fail closed: deny the request with a 503). Set to
201
+ * `true` only when degraded availability is preferable to denying traffic
202
+ * — note that a failing limiter then permits every request through.
203
+ */
204
+ failOpen?: boolean;
205
+ /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
206
+ key?: (context: Context) => string | undefined;
207
+ /** Override the error message thrown on rejection. */
208
+ message?: string;
209
+ }
210
+ /**
211
+ * Procedure middleware that enforces a named rate limit before the handler
212
+ * runs. Attach it with `.use()`. On rejection it throws a structural
213
+ * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
+ * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
215
+ * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
+ *
217
+ * **Failure policy:** if resolving or invoking the limiter throws for a genuine
218
+ * availability reason (e.g. the persistence store is unavailable), the
219
+ * middleware **fails closed by default**: it logs via `console.error` and
220
+ * rejects the request with `503`. This is the safer default for
221
+ * security-sensitive limits (auth, account creation). Pass `failOpen: true` to
222
+ * swallow the error and admit the request instead — appropriate only when
223
+ * degraded availability is preferable to refusal. Deterministic caller misuse
224
+ * (an unconfigured limit name, a non-positive count, or a count that exceeds
225
+ * capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
226
+ * **both** policies — a config bug is never masked as a 503 or silently admitted.
227
+ */
228
+ declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
229
+ /**
230
+ * In-memory store. State lives for the lifetime of the process (or, inside a
231
+ * Durable Object, the instance) — adequate for single-DO limits but not shared
232
+ * across instances. Use {@link createSqlStore} for durable per-DO state.
233
+ */
234
+ declare const createMemoryStore: () => RateLimitStore;
235
+ /**
236
+ * Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
237
+ * satisfied by `node:sqlite`). Only the `exec` overload is required.
238
+ */
239
+ interface SqlLike {
240
+ exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
241
+ toArray: () => Row[];
242
+ };
243
+ }
244
+ interface SqlStoreOptions {
245
+ sql: SqlLike;
246
+ /** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
247
+ table?: string;
248
+ }
249
+ /**
250
+ * SQLite-backed store for durable, per-DO rate-limit state. Persists each
251
+ * `(name, key)` pair as one row so limits survive hibernation and eviction.
252
+ *
253
+ * **Atomicity:** the store does not wrap individual operations in an explicit
254
+ * SQL transaction. Inside a Durable Object the DO's input gate serializes
255
+ * every RPC call against the storage, so the limiter's read-modify-write
256
+ * sequence runs to completion without interleaving — this is the same
257
+ * guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
258
+ * (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
259
+ * tests or a custom host) the caller is responsible for serialization; the
260
+ * SQL surface used here (`exec`) is not a substitute for transactional
261
+ * isolation across concurrent invocations.
262
+ */
263
+ declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
264
+ /**
265
+ * The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
266
+ * `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
267
+ * assignable; only `eq` is exercised.
268
+ */
269
+ interface RateLimitDatabaseIndexRange {
270
+ eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
271
+ gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
272
+ gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
273
+ lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
274
+ lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
275
+ }
276
+ /** The slice of a `ctx.db` table query the store relies on. */
277
+ interface RateLimitDatabaseQuery {
278
+ first: () => Promise<Record<string, unknown> | null>;
279
+ withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
280
+ }
281
+ /**
282
+ * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
283
+ * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
284
+ * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
285
+ * free of a runtime dependency on `@lunora/server`.
286
+ */
287
+ interface RateLimitDatabase {
288
+ delete: <T extends string>(id: Id<T>) => Promise<void>;
289
+ insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
290
+ patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
291
+ query: (table: string) => RateLimitDatabaseQuery;
292
+ }
293
+ interface DatabaseStoreOptions {
294
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
295
+ db: RateLimitDatabase;
296
+ /** Index that resolves a row by its key column. Defaults to `by_key`. */
297
+ index?: string;
298
+ /** Column storing the opaque key. Defaults to `key`. */
299
+ keyField?: string;
300
+ /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
301
+ table?: string;
302
+ }
303
+ /**
304
+ * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
305
+ * inside a procedure (the procedure context exposes no raw SQL). Declare a
306
+ * table with the key column and its index, e.g.
307
+ *
308
+ * ```ts
309
+ * rateLimits: defineTable({
310
+ * key: v.string(),
311
+ * ts: v.number(),
312
+ * value: v.number(),
313
+ * prev: v.optional(v.number()),
314
+ * }).index("by_key", ["key"])
315
+ * ```
316
+ *
317
+ * Each operation is a read-then-write; inside a mutation/action that pair runs
318
+ * under the DO's input gate, so it is atomic against concurrent calls.
319
+ */
320
+ declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
321
+ /**
322
+ * DB-backed rate-limit middleware sugar. Collapses the common
323
+ *
324
+ * ```ts
325
+ * rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
326
+ * ```
327
+ *
328
+ * into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
329
+ * whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
330
+ * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
331
+ * compatible `db`, so it slots straight into a `.use(...)` chain.
332
+ *
333
+ * Pass `options.store` to point at a non-default backing table/index/key column
334
+ * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
335
+ * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
336
+ * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
337
+ * autocompletes to its declared limit names.
338
+ *
339
+ * Re-exported as `dbRateLimit` from the package root.
340
+ *
341
+ * ```ts
342
+ * const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
343
+ *
344
+ * export const send = mutation
345
+ * .input({ text: v.string() })
346
+ * .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
347
+ * .mutation(async ({ ctx, args }) => ...);
348
+ * ```
349
+ */
350
+ declare const databaseRateLimit: <Context extends {
351
+ db: RateLimitDatabase;
352
+ }, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
353
+ store?: Omit<DatabaseStoreOptions, "db">;
354
+ }) => Middleware<Context, Context>;
355
+ /**
356
+ * Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
357
+ * `LunoraError` subclass whose code/status track `status.reason`: a rate
358
+ * rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
359
+ * the same mapping the middleware applies, so both entry points surface the
360
+ * identical wire code (a permanent deny is never a retryable 429). The
361
+ * middleware itself throws a bare structural `LunoraError`, so this is for
362
+ * direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
363
+ */
364
+ declare class RateLimitError extends LunoraError {
365
+ readonly reason: RateLimitReason | undefined;
366
+ readonly retryAfter: number;
367
+ constructor(status: RateLimitStatus, message?: string);
368
+ }
369
+ /** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
370
+ interface RatelimitApiContext<Context> {
371
+ api: (Context extends {
372
+ api: infer A;
373
+ } ? A : Record<never, never>) & {
374
+ ratelimit: RateLimiter;
375
+ };
376
+ }
377
+ /**
378
+ * Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
379
+ * form of the plugin contract: instead of (or alongside) the enforcing
380
+ * `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
381
+ * under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
382
+ * programmatically.
383
+ *
384
+ * Install the middleware with one `.use(...)` (or fold it in with
385
+ * `composePluginMiddleware([...])`):
386
+ *
387
+ * ```ts
388
+ * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
389
+ * const c = initLunora.dataModel&lt;DataModel>().create();
390
+ * export const send = c.mutation
391
+ * .use(ratelimitPlugin(limiter).middleware!)
392
+ * .mutation(async ({ ctx, args }) => {
393
+ * const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
394
+ * if (!status.ok) throw new Error("slow down");
395
+ * // …
396
+ * });
397
+ * ```
398
+ *
399
+ * The plugin ships no schema extension — the limiter's persistence is whatever
400
+ * store the resolved {@link RateLimiter} was built with — so it is a
401
+ * middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
402
+ *
403
+ * Built as a plain {@link Plugin} literal (the key is the fixed string
404
+ * `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
405
+ * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
406
+ */
407
+ declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
408
+ 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 };
package/dist/index.mjs ADDED
@@ -0,0 +1,11 @@
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 };
@@ -0,0 +1,21 @@
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 };
@@ -0,0 +1,121 @@
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 };
@@ -0,0 +1,114 @@
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 };