@ayepi/rate 0.2.0 → 0.4.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/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.4.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.4.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.4.0"
78
78
  },
79
79
  "keywords": [
80
80
  "ayepi",