@ayepi/rate 0.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.cjs +337 -0
- package/dist/index.d.cts +189 -0
- package/dist/index.d.ts +189 -0
- package/dist/index.js +331 -0
- package/dist/redis.cjs +103 -0
- package/dist/redis.d.cts +21 -0
- package/dist/redis.d.ts +21 -0
- package/dist/redis.js +102 -0
- package/dist/server.cjs +66 -0
- package/dist/server.d.cts +67 -0
- package/dist/server.d.ts +67 -0
- package/dist/server.js +65 -0
- package/package.json +94 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Algorithm, RateKeyIO, RateLimitInfo, RateLimitStore, rateLimit as rateLimit$1 } from "./index.cjs";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
|
|
3
|
+
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
|
|
6
|
+
/** The `requires` chain of a middleware def. */
|
|
7
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
8
|
+
/**
|
|
9
|
+
* Server-side options for binding a {@link rateLimit} def — the limiting policy and
|
|
10
|
+
* response customization, with `key`/`skip`/`message` typed against the def's
|
|
11
|
+
* `requires` context.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam M - the rate-limit def being bound.
|
|
14
|
+
*/
|
|
15
|
+
interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
16
|
+
/** 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;
|
|
24
|
+
/** Backend store (default an in-process memory store). */
|
|
25
|
+
readonly store?: RateLimitStore;
|
|
26
|
+
/** Key prefix/namespace (default `'rl:'`). */
|
|
27
|
+
readonly prefix?: string;
|
|
28
|
+
/** Over-limit status code (default `429`). */
|
|
29
|
+
readonly status?: number;
|
|
30
|
+
/** 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);
|
|
32
|
+
/** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
|
|
33
|
+
readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
|
|
34
|
+
/**
|
|
35
|
+
* Also emit the `RateLimit-*` headers on **allowed** (and skipped) responses, not
|
|
36
|
+
* just the over-limit 429 — so every response advertises the caller's remaining
|
|
37
|
+
* budget. Default `false`. Uses the same {@link RateLimitServerOptions.headers}
|
|
38
|
+
* formatting (`Retry-After` is omitted when not rate-limited).
|
|
39
|
+
*/
|
|
40
|
+
readonly alwaysHeaders?: boolean;
|
|
41
|
+
/** Bypass the limiter for some requests (e.g. an allow-list). */
|
|
42
|
+
readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
|
|
43
|
+
/**
|
|
44
|
+
* What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
|
|
45
|
+
* request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
|
|
46
|
+
* error propagates, so a store outage rejects requests rather than silently lifting the limit.
|
|
47
|
+
*/
|
|
48
|
+
readonly failOpen?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Observe a store error. Fires whether or not {@link failOpen} is set (with `failOpen` the
|
|
51
|
+
* request is then allowed; without it the error still propagates). Off by default; it must
|
|
52
|
+
* not throw — if it does, the throw is ignored.
|
|
53
|
+
*/
|
|
54
|
+
readonly onError?: (err: unknown) => void;
|
|
55
|
+
}
|
|
56
|
+
/** Bind a {@link rateLimit} def to its runtime policy. */
|
|
57
|
+
declare function rateLimitServer<M extends AnyMiddleware>(def: M, opts: RateLimitServerOptions<M>): BoundMiddleware<M>;
|
|
58
|
+
/**
|
|
59
|
+
* The {@link rateLimit} def factory, augmented with a `.server(def, opts)` binder.
|
|
60
|
+
* Import from `@ayepi/rate/server` in your server entry to bind a def created in a
|
|
61
|
+
* frontend-safe spec.
|
|
62
|
+
*/
|
|
63
|
+
declare const rateLimit: typeof rateLimit$1 & {
|
|
64
|
+
server: typeof rateLimitServer;
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { RateLimitServerOptions, rateLimit };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Algorithm, RateKeyIO, RateLimitInfo, RateLimitStore, rateLimit as rateLimit$1 } from "./index.js";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
|
|
3
|
+
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
|
|
6
|
+
/** The `requires` chain of a middleware def. */
|
|
7
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
8
|
+
/**
|
|
9
|
+
* Server-side options for binding a {@link rateLimit} def — the limiting policy and
|
|
10
|
+
* response customization, with `key`/`skip`/`message` typed against the def's
|
|
11
|
+
* `requires` context.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam M - the rate-limit def being bound.
|
|
14
|
+
*/
|
|
15
|
+
interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
16
|
+
/** 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;
|
|
24
|
+
/** Backend store (default an in-process memory store). */
|
|
25
|
+
readonly store?: RateLimitStore;
|
|
26
|
+
/** Key prefix/namespace (default `'rl:'`). */
|
|
27
|
+
readonly prefix?: string;
|
|
28
|
+
/** Over-limit status code (default `429`). */
|
|
29
|
+
readonly status?: number;
|
|
30
|
+
/** 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);
|
|
32
|
+
/** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
|
|
33
|
+
readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
|
|
34
|
+
/**
|
|
35
|
+
* Also emit the `RateLimit-*` headers on **allowed** (and skipped) responses, not
|
|
36
|
+
* just the over-limit 429 — so every response advertises the caller's remaining
|
|
37
|
+
* budget. Default `false`. Uses the same {@link RateLimitServerOptions.headers}
|
|
38
|
+
* formatting (`Retry-After` is omitted when not rate-limited).
|
|
39
|
+
*/
|
|
40
|
+
readonly alwaysHeaders?: boolean;
|
|
41
|
+
/** Bypass the limiter for some requests (e.g. an allow-list). */
|
|
42
|
+
readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
|
|
43
|
+
/**
|
|
44
|
+
* What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
|
|
45
|
+
* request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
|
|
46
|
+
* error propagates, so a store outage rejects requests rather than silently lifting the limit.
|
|
47
|
+
*/
|
|
48
|
+
readonly failOpen?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Observe a store error. Fires whether or not {@link failOpen} is set (with `failOpen` the
|
|
51
|
+
* request is then allowed; without it the error still propagates). Off by default; it must
|
|
52
|
+
* not throw — if it does, the throw is ignored.
|
|
53
|
+
*/
|
|
54
|
+
readonly onError?: (err: unknown) => void;
|
|
55
|
+
}
|
|
56
|
+
/** Bind a {@link rateLimit} def to its runtime policy. */
|
|
57
|
+
declare function rateLimitServer<M extends AnyMiddleware>(def: M, opts: RateLimitServerOptions<M>): BoundMiddleware<M>;
|
|
58
|
+
/**
|
|
59
|
+
* The {@link rateLimit} def factory, augmented with a `.server(def, opts)` binder.
|
|
60
|
+
* Import from `@ayepi/rate/server` in your server entry to bind a def created in a
|
|
61
|
+
* frontend-safe spec.
|
|
62
|
+
*/
|
|
63
|
+
declare const rateLimit: typeof rateLimit$1 & {
|
|
64
|
+
server: typeof rateLimitServer;
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { RateLimitServerOptions, rateLimit };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { limiter, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
|
|
2
|
+
//#region src/server.ts
|
|
3
|
+
/** Bind a {@link rateLimit} def to its runtime policy. */
|
|
4
|
+
function rateLimitServer(def, opts) {
|
|
5
|
+
const lim = limiter(opts);
|
|
6
|
+
const message = opts.message;
|
|
7
|
+
const run = async (io) => {
|
|
8
|
+
const kio = {
|
|
9
|
+
req: io.req,
|
|
10
|
+
ctx: io.ctx
|
|
11
|
+
};
|
|
12
|
+
const advertise = (info) => {
|
|
13
|
+
if (!opts.alwaysHeaders) return;
|
|
14
|
+
for (const [name, value] of Object.entries(rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
|
|
15
|
+
};
|
|
16
|
+
const fullBudget = () => ({
|
|
17
|
+
limit: lim.rule.limit,
|
|
18
|
+
remaining: lim.rule.limit,
|
|
19
|
+
reset: 0,
|
|
20
|
+
retryAfter: 0
|
|
21
|
+
});
|
|
22
|
+
if (opts.skip?.(kio)) {
|
|
23
|
+
const skipped = fullBudget();
|
|
24
|
+
advertise(skipped);
|
|
25
|
+
return io.next({ ratelimit: skipped });
|
|
26
|
+
}
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await lim.check(opts.key(kio));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
try {
|
|
32
|
+
opts.onError?.(err);
|
|
33
|
+
} catch {}
|
|
34
|
+
if (!opts.failOpen) throw err;
|
|
35
|
+
const allowed = fullBudget();
|
|
36
|
+
advertise(allowed);
|
|
37
|
+
return io.next({ ratelimit: allowed });
|
|
38
|
+
}
|
|
39
|
+
const info = {
|
|
40
|
+
limit: result.limit,
|
|
41
|
+
remaining: result.remaining,
|
|
42
|
+
reset: result.reset,
|
|
43
|
+
retryAfter: result.retryAfter
|
|
44
|
+
};
|
|
45
|
+
if (!result.allowed) return rateLimitResponse(info, {
|
|
46
|
+
status: opts.status,
|
|
47
|
+
headers: opts.headers,
|
|
48
|
+
message: typeof message === "function" ? (i) => message(i, kio) : message
|
|
49
|
+
});
|
|
50
|
+
advertise(info);
|
|
51
|
+
return io.next({ ratelimit: info });
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
def,
|
|
55
|
+
impl: run
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The {@link rateLimit} def factory, augmented with a `.server(def, opts)` binder.
|
|
60
|
+
* Import from `@ayepi/rate/server` in your server entry to bind a def created in a
|
|
61
|
+
* frontend-safe spec.
|
|
62
|
+
*/
|
|
63
|
+
const rateLimit = Object.assign(rateLimit$1, { server: rateLimitServer });
|
|
64
|
+
//#endregion
|
|
65
|
+
export { rateLimit };
|
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/rate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rate-limiting middleware for @ayepi/core — pluggable stores, multiple algorithms, customizable 429 responses",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/rate"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/rate#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/server.d.ts",
|
|
37
|
+
"default": "./dist/server.js"
|
|
38
|
+
},
|
|
39
|
+
"require": {
|
|
40
|
+
"types": "./dist/server.d.cts",
|
|
41
|
+
"default": "./dist/server.cjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"./redis": {
|
|
45
|
+
"import": {
|
|
46
|
+
"types": "./dist/redis.d.ts",
|
|
47
|
+
"default": "./dist/redis.js"
|
|
48
|
+
},
|
|
49
|
+
"require": {
|
|
50
|
+
"types": "./dist/redis.d.cts",
|
|
51
|
+
"default": "./dist/redis.cjs"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"./package.json": "./package.json"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"ioredis": "^5",
|
|
61
|
+
"@ayepi/core": "^0.1.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"ioredis": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
70
|
+
"ioredis": "^5.4.1",
|
|
71
|
+
"publint": "^0.3.0",
|
|
72
|
+
"testcontainers": "^10.13.0",
|
|
73
|
+
"tsdown": "^0.12.0",
|
|
74
|
+
"vitest": "^2.1.8",
|
|
75
|
+
"zod": "^4.4.3",
|
|
76
|
+
"@ayepi/core": "0.1.0"
|
|
77
|
+
},
|
|
78
|
+
"keywords": [
|
|
79
|
+
"ayepi",
|
|
80
|
+
"@ayepi/core",
|
|
81
|
+
"rate-limit",
|
|
82
|
+
"ratelimit",
|
|
83
|
+
"middleware",
|
|
84
|
+
"redis",
|
|
85
|
+
"throttle"
|
|
86
|
+
],
|
|
87
|
+
"scripts": {
|
|
88
|
+
"build": "tsdown",
|
|
89
|
+
"typecheck": "tsc --noEmit",
|
|
90
|
+
"test": "vitest run --coverage",
|
|
91
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
92
|
+
"publint": "publint"
|
|
93
|
+
}
|
|
94
|
+
}
|