@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.
@@ -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 };
@@ -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
+ }