@ayepi/rate 0.2.0 → 0.3.0

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
@@ -49,6 +49,9 @@ retryAfter }`); on exceeded requests the chain short-circuits with the 429.
49
49
  - `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
50
50
  move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
51
51
  message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
52
+ The rule fields — `limit`, `window`, `algorithm`, `countRejected` — each accept a fixed
53
+ value **or** a per-request `(io) => value`, so limits can differ by caller (e.g.
54
+ `limit: (io) => io.ctx.user.plan === 'pro' ? 1000 : 100`).
52
55
 
53
56
  ## Standalone (without middleware)
54
57
 
package/ayepi-rate.md CHANGED
@@ -115,7 +115,8 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
115
115
  | Export | Kind | Purpose |
116
116
  | --- | --- | --- |
117
117
  | `rateLimit` | function | Same name, **augmented with `.server(def, opts)`** to bind the policy. |
118
- | `RateLimitServerOptions` | interface | The policy options for `.server` (extends `LimiterOptions`). |
118
+ | `RateLimitServerOptions` | interface | The policy options for `.server` (rule fields accept a static value or a per-request `(io) => T`). |
119
+ | `RateKeyFn` / `Dynamic` | type | Helpers: `RateKeyFn<M, T> = (io) => T`; `Dynamic<M, T> = T \| RateKeyFn<M, T>` — the shape of every per-request option. |
119
120
 
120
121
  ### Redis subpath `@ayepi/rate/redis`
121
122
 
@@ -158,10 +159,10 @@ it in another middleware's `requires` (see `ayepi-core-middleware.md`).
158
159
  ### The impl (`@ayepi/rate/server`)
159
160
 
160
161
  ```ts
161
- rateLimit.server: <const R extends readonly AnyMiddleware[]>(
162
- def: RateLimitDef<{ ratelimit: RateLimitInfo }, R>,
163
- opts: RateLimitServerOptions<StackCtx<R>, R>,
164
- ) => BoundMiddleware // pass to implement(api).middleware(...)
162
+ rateLimit.server: <M extends AnyMiddleware>(
163
+ def: M, // a RateLimitDef — its `requires` types `io.ctx`
164
+ opts: RateLimitServerOptions<M>,
165
+ ) => BoundMiddleware<M> // pass to implement(api).middleware(...)
165
166
  ```
166
167
 
167
168
  `.server` binds the policy and returns the bound pair. It composes with the chainable
@@ -170,20 +171,35 @@ rateLimit.server: <const R extends readonly AnyMiddleware[]>(
170
171
  ### `RateLimitServerOptions`
171
172
 
172
173
  ```ts
173
- interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddleware[]>
174
- extends LimiterOptions {
174
+ // A rule field is either a fixed value or resolved per request from the key `io`.
175
+ type RateKeyFn<M, T> = (io: RateKeyIO<StackCtx<ReqOf<M>>>) => T;
176
+ type Dynamic<M, T> = T | RateKeyFn<M, T>;
177
+
178
+ interface RateLimitServerOptions<M extends AnyMiddleware> {
175
179
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
176
- readonly key: (io: RateKeyIO<Ctx>) => string;
180
+ readonly key: RateKeyFn<M, string>;
181
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
182
+ readonly limit: Dynamic<M, number>;
183
+ /** Window length in ms (also the token refill period) — fixed, or per request. */
184
+ readonly window: Dynamic<M, number>;
185
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
186
+ readonly algorithm?: Dynamic<M, Algorithm>;
187
+ /** Count rejected (over-limit) requests against the limit (default `false`) — fixed, or per request. */
188
+ readonly countRejected?: Dynamic<M, boolean>;
189
+ /** Backend store (default an in-process `memoryStore`). */
190
+ readonly store?: RateLimitStore;
191
+ /** Key prefix/namespace (default `'rl:'`). */
192
+ readonly prefix?: string;
177
193
  /** Over-limit status code (default `429`). */
178
194
  readonly status?: number;
179
195
  /** Over-limit body — string, JSON value, or a function of (info, io). */
180
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<Ctx>) => string | Json);
196
+ readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
181
197
  /** Response headers (see `RateLimitResponseOptions`). */
182
198
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
183
199
  /** Also emit the `RateLimit-*` headers on allowed/skipped responses (default `false`). */
184
200
  readonly alwaysHeaders?: boolean;
185
201
  /** Bypass the limiter for some requests (e.g. an allow-list). */
186
- readonly skip?: (io: RateKeyIO<Ctx>) => boolean;
202
+ readonly skip?: RateKeyFn<M, boolean>;
187
203
  /** Serve through (as allowed) when the **store** errors, instead of failing the request. Default `false` (fail-closed). */
188
204
  readonly failOpen?: boolean;
189
205
  /** Observe a store error (e.g. Redis down). Fires regardless of `failOpen`. Off by default; must not throw. */
@@ -191,30 +207,24 @@ interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddl
191
207
  }
192
208
  ```
193
209
 
210
+ > **Dynamic policy.** `limit`, `window`, `algorithm`, and `countRejected` each accept a plain
211
+ > value **or** a `(io) => value` resolved per request — so different callers get different
212
+ > limits (e.g. a higher `limit` for a premium plan, a longer `window` for a trusted key). `io`
213
+ > is the same `{ req, ctx }` `key`/`skip` receive, with `io.ctx` typed from the def's `requires`.
214
+ > Resolve `window`/`algorithm` from a **stable** property of the identity (a given key should
215
+ > always map to the same window/algorithm) — the store buckets its per-key state by them, so
216
+ > varying them for one key across requests gives inconsistent accounting. Varying only `limit`
217
+ > is always safe.
218
+
194
219
  > **Store errors.** By default the limiter is **fail-closed**: if the store (e.g. a Redis
195
220
  > outage) throws, the error propagates and the request is rejected — a store outage doesn't
196
221
  > silently lift the limit. Set `failOpen: true` to serve such requests through instead, and/or
197
222
  > `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
198
223
  > too: a store error there is reported and admission retried — it never strands pending tasks.
199
224
 
200
- …plus everything from `LimiterOptions`:
201
-
202
- ```ts
203
- interface LimiterOptions {
204
- /** Max requests (or token-bucket capacity) per window. */
205
- readonly limit: number;
206
- /** Window length in milliseconds (also the token refill period). */
207
- readonly window: number;
208
- /** Algorithm (default `'fixed-window'`). */
209
- readonly algorithm?: Algorithm;
210
- /** Backend store (default an in-process `memoryStore`). */
211
- readonly store?: RateLimitStore;
212
- /** Key prefix/namespace (default `'rl:'`). */
213
- readonly prefix?: string;
214
- /** Count requests that are themselves rejected (over-limit) against the limit (default `false`). */
215
- readonly countRejected?: boolean;
216
- }
217
- ```
225
+ > The standalone `limiter()` primitive (and `rateLimitedDoer`) still takes a **static**
226
+ > `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
227
+ > resolution is a middleware feature, since only the request path carries an `io`.
218
228
 
219
229
  Notes grounded in the source:
220
230
 
@@ -304,6 +314,24 @@ const app = implement(api)
304
314
  .server()
305
315
  ```
306
316
 
317
+ ### Example — per-caller limits (dynamic `limit`)
318
+
319
+ ```ts
320
+ // shared.ts — auth provides { user: { id, plan } }
321
+ const limit = rateLimit({ requires: [auth] })
322
+ // server.ts
323
+ rateLimit.server(limit, {
324
+ key: (io) => io.ctx.user.id,
325
+ limit: (io) => (io.ctx.user.plan === 'pro' ? 1_000 : 100), // premium callers get more
326
+ window: 60_000, // keep window/algorithm stable per key
327
+ algorithm: 'sliding-window',
328
+ })
329
+ ```
330
+
331
+ Any of `limit` / `window` / `algorithm` / `countRejected` can be a `(io) => value`. Derive
332
+ `window`/`algorithm` from a stable identity attribute (plan, tenant) — not from time or the
333
+ request path — so a given key always maps to the same bucketing.
334
+
307
335
  ### Example — per-IP limiting without `requires`
308
336
 
309
337
  ```ts
package/dist/index.cjs CHANGED
@@ -66,10 +66,10 @@ function limiter(opts) {
66
66
  const rule = {
67
67
  limit: opts.limit,
68
68
  window: opts.window,
69
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
69
+ algorithm: opts.algorithm ?? "fixed-window",
70
70
  countRejected: opts.countRejected ?? false
71
71
  };
72
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
72
+ const prefix = opts.prefix ?? "rl:";
73
73
  return {
74
74
  rule,
75
75
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -329,6 +329,8 @@ function memoryStore() {
329
329
  };
330
330
  }
331
331
  //#endregion
332
+ exports.DEFAULT_ALGORITHM = DEFAULT_ALGORITHM;
333
+ exports.DEFAULT_PREFIX = DEFAULT_PREFIX;
332
334
  exports.limiter = limiter;
333
335
  exports.memoryStore = memoryStore;
334
336
  exports.rateLimit = rateLimit;
package/dist/index.d.cts CHANGED
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -186,4 +190,4 @@ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
186
190
  */
187
191
  declare function memoryStore(): RateLimitStore;
188
192
  //#endregion
189
- export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
193
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -186,4 +190,4 @@ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
186
190
  */
187
191
  declare function memoryStore(): RateLimitStore;
188
192
  //#endregion
189
- export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
193
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.js CHANGED
@@ -65,10 +65,10 @@ function limiter(opts) {
65
65
  const rule = {
66
66
  limit: opts.limit,
67
67
  window: opts.window,
68
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
68
+ algorithm: opts.algorithm ?? "fixed-window",
69
69
  countRejected: opts.countRejected ?? false
70
70
  };
71
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
71
+ const prefix = opts.prefix ?? "rl:";
72
72
  return {
73
73
  rule,
74
74
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -328,4 +328,4 @@ function memoryStore() {
328
328
  };
329
329
  }
330
330
  //#endregion
331
- export { limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
331
+ export { DEFAULT_ALGORITHM, DEFAULT_PREFIX, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/server.cjs CHANGED
@@ -1,22 +1,34 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_index = require("./index.cjs");
3
3
  //#region src/server.ts
4
+ /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
5
+ function resolve(value, io) {
6
+ return typeof value === "function" ? value(io) : value;
7
+ }
4
8
  /** Bind a {@link rateLimit} def to its runtime policy. */
5
9
  function rateLimitServer(def, opts) {
6
- const lim = require_index.limiter(opts);
10
+ const store = opts.store ?? require_index.memoryStore();
11
+ const prefix = opts.prefix ?? "rl:";
7
12
  const message = opts.message;
13
+ const ruleFor = (io) => ({
14
+ limit: resolve(opts.limit, io),
15
+ window: resolve(opts.window, io),
16
+ algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
17
+ countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
18
+ });
8
19
  const run = async (io) => {
9
20
  const kio = {
10
21
  req: io.req,
11
22
  ctx: io.ctx
12
23
  };
24
+ const rule = ruleFor(kio);
13
25
  const advertise = (info) => {
14
26
  if (!opts.alwaysHeaders) return;
15
27
  for (const [name, value] of Object.entries(require_index.rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
16
28
  };
17
29
  const fullBudget = () => ({
18
- limit: lim.rule.limit,
19
- remaining: lim.rule.limit,
30
+ limit: rule.limit,
31
+ remaining: rule.limit,
20
32
  reset: 0,
21
33
  retryAfter: 0
22
34
  });
@@ -27,7 +39,7 @@ function rateLimitServer(def, opts) {
27
39
  }
28
40
  let result;
29
41
  try {
30
- result = await lim.check(opts.key(kio));
42
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now());
31
43
  } catch (err) {
32
44
  try {
33
45
  opts.onError?.(err);
package/dist/server.d.cts CHANGED
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
5
5
 
6
6
  /** The `requires` chain of a middleware def. */
7
7
  type ReqOf<M extends AnyMiddleware> = M['__req'];
8
+ /** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
9
+ type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
10
+ /**
11
+ * A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
12
+ * `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
13
+ */
14
+ type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
15
+ /**
16
+ * A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
17
+ * from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
18
+ * `limit` for a premium plan, a longer `window` for a trusted client.
19
+ */
20
+ type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
8
21
  /**
9
22
  * Server-side options for binding a {@link rateLimit} def — the limiting policy and
10
23
  * response customization, with `key`/`skip`/`message` typed against the def's
11
24
  * `requires` context.
12
25
  *
26
+ * The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
27
+ * a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
28
+ * **Resolve these from a stable property of the identity** (the same key should always map to
29
+ * the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
30
+ * `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
31
+ * `limit` is always safe. See {@link Dynamic}.
32
+ *
13
33
  * @typeParam M - the rate-limit def being bound.
14
34
  */
15
35
  interface RateLimitServerOptions<M extends AnyMiddleware> {
16
36
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
17
- readonly key: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => string;
18
- /** Max requests (or token-bucket capacity) per window. */
19
- readonly limit: number;
20
- /** Window length in milliseconds (also the token refill period). */
21
- readonly window: number;
22
- /** Algorithm (default `'fixed-window'`). */
23
- readonly algorithm?: Algorithm;
37
+ readonly key: RateKeyFn<M, string>;
38
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
39
+ readonly limit: Dynamic<M, number>;
40
+ /** Window length in milliseconds (also the token refill period) — fixed, or per request. */
41
+ readonly window: Dynamic<M, number>;
42
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
43
+ readonly algorithm?: Dynamic<M, Algorithm>;
44
+ /**
45
+ * Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
46
+ * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
+ */
48
+ readonly countRejected?: Dynamic<M, boolean>;
24
49
  /** Backend store (default an in-process memory store). */
25
50
  readonly store?: RateLimitStore;
26
51
  /** Key prefix/namespace (default `'rl:'`). */
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
28
53
  /** Over-limit status code (default `429`). */
29
54
  readonly status?: number;
30
55
  /** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
31
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
56
+ readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
32
57
  /** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
33
58
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
34
59
  /**
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
39
64
  */
40
65
  readonly alwaysHeaders?: boolean;
41
66
  /** Bypass the limiter for some requests (e.g. an allow-list). */
42
- readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
67
+ readonly skip?: RateKeyFn<M, boolean>;
43
68
  /**
44
69
  * What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
45
70
  * request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
64
89
  server: typeof rateLimitServer;
65
90
  };
66
91
  //#endregion
67
- export { RateLimitServerOptions, rateLimit };
92
+ export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };
package/dist/server.d.ts CHANGED
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
5
5
 
6
6
  /** The `requires` chain of a middleware def. */
7
7
  type ReqOf<M extends AnyMiddleware> = M['__req'];
8
+ /** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
9
+ type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
10
+ /**
11
+ * A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
12
+ * `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
13
+ */
14
+ type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
15
+ /**
16
+ * A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
17
+ * from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
18
+ * `limit` for a premium plan, a longer `window` for a trusted client.
19
+ */
20
+ type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
8
21
  /**
9
22
  * Server-side options for binding a {@link rateLimit} def — the limiting policy and
10
23
  * response customization, with `key`/`skip`/`message` typed against the def's
11
24
  * `requires` context.
12
25
  *
26
+ * The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
27
+ * a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
28
+ * **Resolve these from a stable property of the identity** (the same key should always map to
29
+ * the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
30
+ * `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
31
+ * `limit` is always safe. See {@link Dynamic}.
32
+ *
13
33
  * @typeParam M - the rate-limit def being bound.
14
34
  */
15
35
  interface RateLimitServerOptions<M extends AnyMiddleware> {
16
36
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
17
- readonly key: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => string;
18
- /** Max requests (or token-bucket capacity) per window. */
19
- readonly limit: number;
20
- /** Window length in milliseconds (also the token refill period). */
21
- readonly window: number;
22
- /** Algorithm (default `'fixed-window'`). */
23
- readonly algorithm?: Algorithm;
37
+ readonly key: RateKeyFn<M, string>;
38
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
39
+ readonly limit: Dynamic<M, number>;
40
+ /** Window length in milliseconds (also the token refill period) — fixed, or per request. */
41
+ readonly window: Dynamic<M, number>;
42
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
43
+ readonly algorithm?: Dynamic<M, Algorithm>;
44
+ /**
45
+ * Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
46
+ * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
+ */
48
+ readonly countRejected?: Dynamic<M, boolean>;
24
49
  /** Backend store (default an in-process memory store). */
25
50
  readonly store?: RateLimitStore;
26
51
  /** Key prefix/namespace (default `'rl:'`). */
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
28
53
  /** Over-limit status code (default `429`). */
29
54
  readonly status?: number;
30
55
  /** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
31
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
56
+ readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
32
57
  /** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
33
58
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
34
59
  /**
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
39
64
  */
40
65
  readonly alwaysHeaders?: boolean;
41
66
  /** Bypass the limiter for some requests (e.g. an allow-list). */
42
- readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
67
+ readonly skip?: RateKeyFn<M, boolean>;
43
68
  /**
44
69
  * What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
45
70
  * request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
64
89
  server: typeof rateLimitServer;
65
90
  };
66
91
  //#endregion
67
- export { RateLimitServerOptions, rateLimit };
92
+ export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };
package/dist/server.js CHANGED
@@ -1,21 +1,33 @@
1
- import { limiter, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
1
+ import { memoryStore, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
2
2
  //#region src/server.ts
3
+ /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
4
+ function resolve(value, io) {
5
+ return typeof value === "function" ? value(io) : value;
6
+ }
3
7
  /** Bind a {@link rateLimit} def to its runtime policy. */
4
8
  function rateLimitServer(def, opts) {
5
- const lim = limiter(opts);
9
+ const store = opts.store ?? memoryStore();
10
+ const prefix = opts.prefix ?? "rl:";
6
11
  const message = opts.message;
12
+ const ruleFor = (io) => ({
13
+ limit: resolve(opts.limit, io),
14
+ window: resolve(opts.window, io),
15
+ algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
16
+ countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
17
+ });
7
18
  const run = async (io) => {
8
19
  const kio = {
9
20
  req: io.req,
10
21
  ctx: io.ctx
11
22
  };
23
+ const rule = ruleFor(kio);
12
24
  const advertise = (info) => {
13
25
  if (!opts.alwaysHeaders) return;
14
26
  for (const [name, value] of Object.entries(rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
15
27
  };
16
28
  const fullBudget = () => ({
17
- limit: lim.rule.limit,
18
- remaining: lim.rule.limit,
29
+ limit: rule.limit,
30
+ remaining: rule.limit,
19
31
  reset: 0,
20
32
  retryAfter: 0
21
33
  });
@@ -26,7 +38,7 @@ function rateLimitServer(def, opts) {
26
38
  }
27
39
  let result;
28
40
  try {
29
- result = await lim.check(opts.key(kio));
41
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now());
30
42
  } catch (err) {
31
43
  try {
32
44
  opts.onError?.(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/rate",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Rate-limiting middleware for @ayepi/core — pluggable stores, multiple algorithms, customizable 429 responses",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "ioredis": "^5",
62
- "@ayepi/core": "^0.2.0"
62
+ "@ayepi/core": "^0.3.0"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "ioredis": {
@@ -74,7 +74,7 @@
74
74
  "tsdown": "^0.12.0",
75
75
  "vitest": "^2.1.8",
76
76
  "zod": "^4.4.3",
77
- "@ayepi/core": "0.2.0"
77
+ "@ayepi/core": "0.3.0"
78
78
  },
79
79
  "keywords": [
80
80
  "ayepi",