@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @ayepi/rate
|
|
2
|
+
|
|
3
|
+
Rate-limiting middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
|
|
4
|
+
Derive a key from the request context, pick an algorithm and a store, and exceeded
|
|
5
|
+
requests **short-circuit with a 429** (which also maps to a ws error frame).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @ayepi/rate @ayepi/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`@ayepi/rate` ships as a **def / impl split**. The main entry is frontend-safe and exports
|
|
12
|
+
`rateLimit(opts?)`, a middleware **def factory**. The policy (key, limit, window, store, …)
|
|
13
|
+
is bound on the server via `@ayepi/rate/server`, which augments `rateLimit` with
|
|
14
|
+
`.server(def, opts)`.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// shared.ts — frontend-safe: the def + the spec
|
|
18
|
+
import { rateLimit } from '@ayepi/rate'
|
|
19
|
+
|
|
20
|
+
const limit = rateLimit({ requires: [auth] }) // contributes { ratelimit } to the handler ctx
|
|
21
|
+
|
|
22
|
+
const api = spec({ endpoints: { ...limit.group({ getThing: { … } }) } })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// server.ts — binds the policy
|
|
27
|
+
import { rateLimit } from '@ayepi/rate/server'
|
|
28
|
+
import { implement } from '@ayepi/core'
|
|
29
|
+
|
|
30
|
+
const app = implement(api)
|
|
31
|
+
// 100 requests / minute per authenticated user, sliding window
|
|
32
|
+
.middleware(rateLimit.server(limit, {
|
|
33
|
+
key: (io) => io.ctx.user.id,
|
|
34
|
+
limit: 100,
|
|
35
|
+
window: 60_000,
|
|
36
|
+
algorithm: 'sliding-window',
|
|
37
|
+
}))
|
|
38
|
+
.server()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
On allowed requests the handler gets `ctx.ratelimit` (`{ limit, remaining, reset,
|
|
42
|
+
retryAfter }`); on exceeded requests the chain short-circuits with the 429.
|
|
43
|
+
|
|
44
|
+
## Def vs server
|
|
45
|
+
|
|
46
|
+
- `rateLimit(opts?)` (def factory, `@ayepi/rate`) — frontend-safe. `opts = { name?,
|
|
47
|
+
requires? }`. Declares the contract and **contributes `{ ratelimit }`** to the handler
|
|
48
|
+
context. A spec importing only this entry is safe to bundle for the frontend.
|
|
49
|
+
- `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
|
|
50
|
+
move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
|
|
51
|
+
message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
|
|
52
|
+
|
|
53
|
+
## Standalone (without middleware)
|
|
54
|
+
|
|
55
|
+
The middleware is a thin wrapper over two primitives, **both still on the main `@ayepi/rate`
|
|
56
|
+
entry** (frontend-unrelated; use them anywhere — a handler, a queue/cron worker, a CLI,
|
|
57
|
+
another framework):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { limiter, rateLimitResponse } from '@ayepi/rate'
|
|
61
|
+
|
|
62
|
+
const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
63
|
+
|
|
64
|
+
const { allowed, remaining, retryAfter } = await lim.check(userId)
|
|
65
|
+
if (!allowed) {
|
|
66
|
+
// do whatever you want with the decision:
|
|
67
|
+
throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`) // …or
|
|
68
|
+
return rateLimitResponse({ limit: 100, remaining, reset: 0, retryAfter }) // a ready-made 429
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await lim.reset(userId) // clear a key
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `limiter(opts)` → `{ check(key, now?), reset(key), rule }` — the actual limiting
|
|
75
|
+
(pluggable store + algorithm), no HTTP involved.
|
|
76
|
+
- `rateLimitResponse(info, opts?)` → a `Response` (status/message/headers), if you
|
|
77
|
+
want one.
|
|
78
|
+
|
|
79
|
+
`rateLimit.server()` === `limiter()` + `rateLimitResponse()` + key/skip/requires wiring.
|
|
80
|
+
|
|
81
|
+
## Rate-limited doer (for `@ayepi/work`)
|
|
82
|
+
|
|
83
|
+
`rateLimitedDoer` is a [`Doer`](https://www.npmjs.com/package/@ayepi/core) (from
|
|
84
|
+
`@ayepi/core/doer`) that caps the **start rate** of tasks through the same `limiter()`
|
|
85
|
+
primitive — so an `@ayepi/work` engine processes work no faster than a budget allows.
|
|
86
|
+
It also stays on the main `@ayepi/rate` entry. Excess tasks wait, oldest-first, and a
|
|
87
|
+
distributed store limits across a fleet:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { rateLimitedDoer } from '@ayepi/rate'
|
|
91
|
+
import { createWork } from '@ayepi/work'
|
|
92
|
+
|
|
93
|
+
const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
94
|
+
const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Algorithms
|
|
98
|
+
|
|
99
|
+
- `fixed-window` (default) — simple counter per window.
|
|
100
|
+
- `sliding-window` — weights the previous window for a smoother limit.
|
|
101
|
+
- `token-bucket` — steady rate with bursts up to `limit`.
|
|
102
|
+
|
|
103
|
+
## Stores (cross-instance)
|
|
104
|
+
|
|
105
|
+
The default store is in-memory (single process). To limit across pods, use the
|
|
106
|
+
Redis store (each algorithm runs as one atomic Lua script). The store is a `.server`
|
|
107
|
+
option:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import Redis from 'ioredis'
|
|
111
|
+
import { rateLimit } from '@ayepi/rate/server'
|
|
112
|
+
import { redisStore } from '@ayepi/rate/redis'
|
|
113
|
+
|
|
114
|
+
rateLimit.server(limit, {
|
|
115
|
+
key: (io) => io.ctx.user.id, limit: 100, window: 60_000, store: redisStore(new Redis(url)),
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Implement the `RateLimitStore` interface for any other backend.
|
|
120
|
+
|
|
121
|
+
## Customizing the response
|
|
122
|
+
|
|
123
|
+
All of these are `.server` options:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
rateLimit.server(limit, {
|
|
127
|
+
key: (io) => clientIp(io.req),
|
|
128
|
+
limit: 20,
|
|
129
|
+
window: 1000,
|
|
130
|
+
status: 429, // default 429
|
|
131
|
+
message: (info) => ({ error: 'slow down', retryAfter: info.retryAfter }), // string | JSON | fn
|
|
132
|
+
headers: true, // draft RateLimit-* (+ Retry-After when blocked); false to omit; or a fn for custom headers
|
|
133
|
+
alwaysHeaders: true, // also set RateLimit-* on allowed responses (default false)
|
|
134
|
+
countRejected: false, // default: an over-limit request doesn't count against the limit
|
|
135
|
+
skip: (io) => io.req.headers.get('x-admin') === '1',
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## For AI coding agents
|
|
140
|
+
|
|
141
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
142
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
143
|
+
|
|
144
|
+
- [`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)
|
|
145
|
+
- [`ayepi-rate.md`](./ayepi-rate.md)
|
|
146
|
+
|
|
147
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/rate) and are **not** shipped in the npm tarball.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT © Philip Diffenderfer
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _ayepi_core = require("@ayepi/core");
|
|
3
|
+
let _ayepi_core_doer = require("@ayepi/core/doer");
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* # @ayepi/rate
|
|
7
|
+
*
|
|
8
|
+
* Rate-limiting middleware for ayepi. {@link rateLimit} builds a middleware that
|
|
9
|
+
* derives a **key from the request context** (e.g. the authenticated user, an IP,
|
|
10
|
+
* an API token), checks it against a **store + algorithm**, and — when the limit
|
|
11
|
+
* is exceeded — **short-circuits with a 429 `Response`** (which also maps to a ws
|
|
12
|
+
* error frame). Successful requests expose `ratelimit` info in the handler
|
|
13
|
+
* context.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // shared.ts (frontend-safe): the def declares what it contributes
|
|
17
|
+
* import { rateLimit } from '@ayepi/rate'
|
|
18
|
+
* const limit = rateLimit({ requires: [auth] }) // provides { ratelimit }
|
|
19
|
+
* spec({ endpoints: { ...limit.group({ … }) } })
|
|
20
|
+
*
|
|
21
|
+
* // server.ts: bind the policy (key, limit, window, store)
|
|
22
|
+
* import { rateLimit } from '@ayepi/rate/server'
|
|
23
|
+
* implement(api).middleware(rateLimit.server(limit, {
|
|
24
|
+
* key: (io) => io.ctx.user.id, // io.ctx.user typed via `requires: [auth]`
|
|
25
|
+
* limit: 100,
|
|
26
|
+
* window: 60_000,
|
|
27
|
+
* algorithm: 'sliding-window',
|
|
28
|
+
* }))
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* - **Pluggable store** — in-memory by default ({@link memoryStore}); pass a
|
|
32
|
+
* distributed store (see `@ayepi/rate/redis`) to limit across instances.
|
|
33
|
+
* - **Algorithms** — `fixed-window`, `sliding-window`, `token-bucket`.
|
|
34
|
+
* - **Customizable response** — status, message (text or JSON), and headers
|
|
35
|
+
* (draft `RateLimit-*` + `Retry-After` by default, or your own).
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
/** Default over-limit HTTP status. */
|
|
40
|
+
const DEFAULT_STATUS = 429;
|
|
41
|
+
/** Default key namespace. */
|
|
42
|
+
const DEFAULT_PREFIX = "rl:";
|
|
43
|
+
/** Default algorithm. */
|
|
44
|
+
const DEFAULT_ALGORITHM = "fixed-window";
|
|
45
|
+
/** Tokens consumed per request (token-bucket). */
|
|
46
|
+
const TOKEN_COST = 1;
|
|
47
|
+
/** Milliseconds per second — `Retry-After` / `RateLimit-Reset` are expressed in seconds. */
|
|
48
|
+
const MS_PER_SECOND = 1e3;
|
|
49
|
+
/** Amortized cleanup: sweep expired in-memory entries once every this many `consume` calls. */
|
|
50
|
+
const SWEEP_EVERY = 1e3;
|
|
51
|
+
/** Drop an idle in-memory token bucket after this long with no activity (it refills to full anyway). */
|
|
52
|
+
const BUCKET_IDLE_MS = 600 * 1e3;
|
|
53
|
+
/**
|
|
54
|
+
* Create a standalone {@link Limiter} — the rate-limit primitive the
|
|
55
|
+
* {@link rateLimit} middleware is built on. Use it anywhere you have a key: a
|
|
56
|
+
* plain handler, a queue/cron worker, a CLI, a different framework.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
60
|
+
* const { allowed, retryAfter } = await lim.check(userId)
|
|
61
|
+
* if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function limiter(opts) {
|
|
65
|
+
const store = opts.store ?? memoryStore();
|
|
66
|
+
const rule = {
|
|
67
|
+
limit: opts.limit,
|
|
68
|
+
window: opts.window,
|
|
69
|
+
algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
|
|
70
|
+
countRejected: opts.countRejected ?? false
|
|
71
|
+
};
|
|
72
|
+
const prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
73
|
+
return {
|
|
74
|
+
rule,
|
|
75
|
+
check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
|
|
76
|
+
reset: (key) => Promise.resolve(store.reset?.(prefix + key))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Compute the rate-limit response headers for `info`. `true` (default) emits the
|
|
81
|
+
* draft `RateLimit-Limit`/`-Remaining`/`-Reset` headers — plus `Retry-After` **only
|
|
82
|
+
* when the request was rejected** (`retryAfter > 0`); `false` emits none; a function
|
|
83
|
+
* returns your own map (which **replaces** the defaults).
|
|
84
|
+
*
|
|
85
|
+
* Shared by {@link rateLimitResponse} (the 429) and the middleware's `alwaysHeaders`
|
|
86
|
+
* option (informational headers on allowed responses).
|
|
87
|
+
*/
|
|
88
|
+
function rateLimitHeaders(info, headers = true) {
|
|
89
|
+
if (headers === false) return {};
|
|
90
|
+
if (typeof headers === "function") return { ...headers(info) };
|
|
91
|
+
const out = {
|
|
92
|
+
"ratelimit-limit": String(info.limit),
|
|
93
|
+
"ratelimit-remaining": String(info.remaining),
|
|
94
|
+
"ratelimit-reset": String(Math.ceil(info.reset / MS_PER_SECOND))
|
|
95
|
+
};
|
|
96
|
+
if (info.retryAfter > 0) out["retry-after"] = String(Math.ceil(info.retryAfter / MS_PER_SECOND));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build a rate-limit (429) `Response` from limiter info — usable on its own,
|
|
101
|
+
* outside any middleware (e.g. from a handler that called {@link limiter} directly).
|
|
102
|
+
*/
|
|
103
|
+
function rateLimitResponse(info, opts = {}) {
|
|
104
|
+
const status = opts.status ?? DEFAULT_STATUS;
|
|
105
|
+
const headers = rateLimitHeaders(info, opts.headers);
|
|
106
|
+
const body = typeof opts.message === "function" ? opts.message(info) : opts.message ?? "Too many requests";
|
|
107
|
+
if (typeof body === "string") {
|
|
108
|
+
if (!("content-type" in headers)) headers["content-type"] = "text/plain; charset=utf-8";
|
|
109
|
+
return new Response(body, {
|
|
110
|
+
status,
|
|
111
|
+
headers
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return new Response(JSON.stringify(body), {
|
|
115
|
+
status,
|
|
116
|
+
headers: {
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
...headers
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create a rate-limiting middleware **def**. The def declares what the middleware
|
|
124
|
+
* contributes (`{ ratelimit: RateLimitInfo }`) and its dependencies — but **no**
|
|
125
|
+
* policy. Bind the key/limit/window/store with
|
|
126
|
+
* [`rateLimit.server(def, { key, limit, window })`](./server).
|
|
127
|
+
*
|
|
128
|
+
* @typeParam R - inferred from `requires`; their context types flow into the
|
|
129
|
+
* server-side `key`/`skip`/`message`.
|
|
130
|
+
*/
|
|
131
|
+
function rateLimit(opts) {
|
|
132
|
+
return (0, _ayepi_core.middleware)(opts?.name ?? "rateLimit", {
|
|
133
|
+
provides: (0, _ayepi_core.ctx)(),
|
|
134
|
+
requires: opts?.requires ?? []
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Minimum re-check delay when a deferred task has no explicit retry hint (ms). */
|
|
138
|
+
const DOER_RETRY_FLOOR = 50;
|
|
139
|
+
/**
|
|
140
|
+
* A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
|
|
141
|
+
* standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
|
|
142
|
+
* algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
|
|
143
|
+
* handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
|
|
144
|
+
* cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
|
|
145
|
+
* Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* import { rateLimitedDoer } from '@ayepi/rate'
|
|
149
|
+
* import { createWork } from '@ayepi/work'
|
|
150
|
+
*
|
|
151
|
+
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
152
|
+
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
function rateLimitedDoer(opts) {
|
|
156
|
+
const lim = limiter(opts);
|
|
157
|
+
const inner = opts.doer ?? (0, _ayepi_core_doer.unlimitedDoer)();
|
|
158
|
+
const now = opts.now ?? Date.now;
|
|
159
|
+
const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
|
|
160
|
+
const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
|
|
161
|
+
const pending = [];
|
|
162
|
+
const idle = [];
|
|
163
|
+
let seq = 0;
|
|
164
|
+
let draining = false;
|
|
165
|
+
let timer = null;
|
|
166
|
+
const arm = (ms) => {
|
|
167
|
+
if (timer) return;
|
|
168
|
+
timer = setTimeout(() => {
|
|
169
|
+
timer = null;
|
|
170
|
+
drain();
|
|
171
|
+
}, Math.max(floor, ms));
|
|
172
|
+
timer.unref?.();
|
|
173
|
+
};
|
|
174
|
+
const drain = async () => {
|
|
175
|
+
if (draining) return;
|
|
176
|
+
draining = true;
|
|
177
|
+
try {
|
|
178
|
+
while (pending.length > 0) {
|
|
179
|
+
if (inner.available() <= 0) {
|
|
180
|
+
arm(floor);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
let best = 0;
|
|
184
|
+
for (let i = 1; i < pending.length; i++) {
|
|
185
|
+
const a = pending[i];
|
|
186
|
+
const b = pending[best];
|
|
187
|
+
if (a.createdAt < b.createdAt || a.createdAt === b.createdAt && a.seq < b.seq) best = i;
|
|
188
|
+
}
|
|
189
|
+
const task = pending[best];
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = await lim.check(keyOf(task.opts), now());
|
|
193
|
+
} catch (err) {
|
|
194
|
+
try {
|
|
195
|
+
opts.onError?.(err);
|
|
196
|
+
} catch {}
|
|
197
|
+
arm(floor);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (!res.allowed) {
|
|
201
|
+
arm(res.retryAfter);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
pending.splice(best, 1);
|
|
205
|
+
inner.do(task.run, task.opts);
|
|
206
|
+
}
|
|
207
|
+
if (pending.length === 0) for (const r of idle.splice(0)) r();
|
|
208
|
+
} finally {
|
|
209
|
+
draining = false;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
available: () => Math.min(Math.max(0, lim.rule.limit - pending.length), inner.available()),
|
|
214
|
+
do(task, o) {
|
|
215
|
+
pending.push({
|
|
216
|
+
run: task,
|
|
217
|
+
opts: o,
|
|
218
|
+
createdAt: o?.createdAt ?? now(),
|
|
219
|
+
seq: seq++
|
|
220
|
+
});
|
|
221
|
+
drain();
|
|
222
|
+
},
|
|
223
|
+
done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function fixedWindow(counters, key, rule, now) {
|
|
227
|
+
let e = counters.get(key);
|
|
228
|
+
if (!e || e.reset <= now) {
|
|
229
|
+
e = {
|
|
230
|
+
count: 0,
|
|
231
|
+
reset: now + rule.window
|
|
232
|
+
};
|
|
233
|
+
counters.set(key, e);
|
|
234
|
+
}
|
|
235
|
+
const allowed = e.count < rule.limit;
|
|
236
|
+
if (allowed || rule.countRejected) e.count++;
|
|
237
|
+
const reset = e.reset - now;
|
|
238
|
+
return {
|
|
239
|
+
allowed,
|
|
240
|
+
limit: rule.limit,
|
|
241
|
+
remaining: Math.max(0, rule.limit - e.count),
|
|
242
|
+
reset,
|
|
243
|
+
retryAfter: allowed ? 0 : reset
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function slidingWindow(counters, key, rule, now) {
|
|
247
|
+
const windowStart = Math.floor(now / rule.window) * rule.window;
|
|
248
|
+
const curKey = `${key}|${windowStart}`;
|
|
249
|
+
const prevKey = `${key}|${windowStart - rule.window}`;
|
|
250
|
+
let cur = counters.get(curKey);
|
|
251
|
+
if (!cur || cur.reset <= now) {
|
|
252
|
+
cur = {
|
|
253
|
+
count: 0,
|
|
254
|
+
reset: windowStart + rule.window
|
|
255
|
+
};
|
|
256
|
+
counters.set(curKey, cur);
|
|
257
|
+
}
|
|
258
|
+
const prevCount = counters.get(prevKey)?.count ?? 0;
|
|
259
|
+
const weight = (rule.window - (now - windowStart)) / rule.window;
|
|
260
|
+
const allowed = prevCount * weight + cur.count + 1 <= rule.limit;
|
|
261
|
+
if (allowed || rule.countRejected) cur.count++;
|
|
262
|
+
const weighted = prevCount * weight + cur.count;
|
|
263
|
+
const reset = windowStart + rule.window - now;
|
|
264
|
+
return {
|
|
265
|
+
allowed,
|
|
266
|
+
limit: rule.limit,
|
|
267
|
+
remaining: Math.max(0, Math.floor(rule.limit - weighted)),
|
|
268
|
+
reset,
|
|
269
|
+
retryAfter: allowed ? 0 : reset
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function tokenBucket(buckets, key, rule, now) {
|
|
273
|
+
const cap = rule.limit;
|
|
274
|
+
const refillPerMs = rule.limit / rule.window;
|
|
275
|
+
let b = buckets.get(key);
|
|
276
|
+
if (!b) b = {
|
|
277
|
+
tokens: cap,
|
|
278
|
+
ts: now
|
|
279
|
+
};
|
|
280
|
+
b.tokens = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
|
|
281
|
+
b.ts = now;
|
|
282
|
+
const cost = TOKEN_COST;
|
|
283
|
+
let allowed = false;
|
|
284
|
+
if (b.tokens >= cost) {
|
|
285
|
+
b.tokens -= cost;
|
|
286
|
+
allowed = true;
|
|
287
|
+
}
|
|
288
|
+
buckets.set(key, b);
|
|
289
|
+
const remaining = Math.floor(b.tokens);
|
|
290
|
+
const retryAfter = allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerMs);
|
|
291
|
+
const reset = Math.ceil((cap - b.tokens) / refillPerMs);
|
|
292
|
+
return {
|
|
293
|
+
allowed,
|
|
294
|
+
limit: cap,
|
|
295
|
+
remaining,
|
|
296
|
+
reset,
|
|
297
|
+
retryAfter
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
302
|
+
* default store — fine for a single instance; use a distributed store (e.g.
|
|
303
|
+
* `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
|
|
304
|
+
* lazily.
|
|
305
|
+
*/
|
|
306
|
+
function memoryStore() {
|
|
307
|
+
const counters = /* @__PURE__ */ new Map();
|
|
308
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
309
|
+
let ops = 0;
|
|
310
|
+
const sweep = (now) => {
|
|
311
|
+
if (++ops % SWEEP_EVERY !== 0) return;
|
|
312
|
+
for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
|
|
313
|
+
for (const [k, b] of buckets) if (now - b.ts > BUCKET_IDLE_MS) buckets.delete(k);
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
consume(key, rule, now) {
|
|
317
|
+
sweep(now);
|
|
318
|
+
switch (rule.algorithm) {
|
|
319
|
+
case "sliding-window": return slidingWindow(counters, key, rule, now);
|
|
320
|
+
case "token-bucket": return tokenBucket(buckets, key, rule, now);
|
|
321
|
+
default: return fixedWindow(counters, key, rule, now);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
reset(key) {
|
|
325
|
+
counters.delete(key);
|
|
326
|
+
for (const k of counters.keys()) if (k.startsWith(`${key}|`)) counters.delete(k);
|
|
327
|
+
buckets.delete(key);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
exports.limiter = limiter;
|
|
333
|
+
exports.memoryStore = memoryStore;
|
|
334
|
+
exports.rateLimit = rateLimit;
|
|
335
|
+
exports.rateLimitHeaders = rateLimitHeaders;
|
|
336
|
+
exports.rateLimitResponse = rateLimitResponse;
|
|
337
|
+
exports.rateLimitedDoer = rateLimitedDoer;
|