@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 +3 -0
- package/ayepi-rate.md +56 -28
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -3
- package/dist/server.cjs +16 -4
- package/dist/server.d.cts +35 -10
- package/dist/server.d.ts +35 -10
- package/dist/server.js +17 -5
- package/package.json +3 -3
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` (
|
|
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: <
|
|
162
|
-
def: RateLimitDef
|
|
163
|
-
opts: RateLimitServerOptions<
|
|
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
|
-
|
|
174
|
-
|
|
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:
|
|
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<
|
|
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?:
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 ??
|
|
69
|
+
algorithm: opts.algorithm ?? "fixed-window",
|
|
70
70
|
countRejected: opts.countRejected ?? false
|
|
71
71
|
};
|
|
72
|
-
const prefix = opts.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 ??
|
|
68
|
+
algorithm: opts.algorithm ?? "fixed-window",
|
|
69
69
|
countRejected: opts.countRejected ?? false
|
|
70
70
|
};
|
|
71
|
-
const prefix = opts.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
|
|
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:
|
|
19
|
-
remaining:
|
|
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
|
|
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:
|
|
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:
|
|
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?:
|
|
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:
|
|
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:
|
|
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?:
|
|
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 {
|
|
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
|
|
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:
|
|
18
|
-
remaining:
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
77
|
+
"@ayepi/core": "0.3.0"
|
|
78
78
|
},
|
|
79
79
|
"keywords": [
|
|
80
80
|
"ayepi",
|