ratefall 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 +158 -0
- package/dist/index.cjs +250 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +147 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +243 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Spatari
|
|
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,158 @@
|
|
|
1
|
+
# ratefall
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ratefall)
|
|
4
|
+
[](https://github.com/thtsdaniel/ratefall/actions)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](./package.json)
|
|
7
|
+
|
|
8
|
+
A rate limiter that just works: distributed (Redis) in production, in-memory in development, the same code in both. No store wiring, no `if (env) new Redis()` ternary in every app.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { rateLimit } from 'ratefall'
|
|
12
|
+
|
|
13
|
+
const limiter = rateLimit({ limit: 10, window: '10s' })
|
|
14
|
+
|
|
15
|
+
const { success, remaining, retryAfter } = await limiter.limit(userId)
|
|
16
|
+
if (!success) {
|
|
17
|
+
return new Response('slow down', { status: 429 })
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That snippet runs on an in-memory counter locally with zero config. Set two Upstash env vars in production and the identical code becomes distributed. Nothing else changes.
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
Every app needs rate limiting, and every app ends up with the same boilerplate: a Redis client in production, an in-memory shim for local dev, and a branch to pick between them. `ratefall` collapses that into one call. It resolves the store for you, keeps the API identical across environments, and warns loudly if you ever ship an un-shared limiter to production by accident.
|
|
26
|
+
|
|
27
|
+
- **Zero dependencies.** Nothing pulled into your bundle.
|
|
28
|
+
- **Edge and serverless ready.** The built-in Upstash store uses `fetch`, so it runs on Node 18+, Vercel Edge, and Cloudflare Workers.
|
|
29
|
+
- **Bring your own store.** Pass an `@upstash/redis` or `ioredis` client, or a fully custom store.
|
|
30
|
+
- **Two algorithms.** Fixed window (cheap) and sliding window (smooth).
|
|
31
|
+
- **Typed end to end.** Written in TypeScript, ships ESM and CJS.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install ratefall
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Same code, dev to prod
|
|
40
|
+
|
|
41
|
+
`ratefall` picks a store in this order:
|
|
42
|
+
|
|
43
|
+
1. an explicit `store` you pass
|
|
44
|
+
2. an explicit `redis` client you pass
|
|
45
|
+
3. `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` in the environment
|
|
46
|
+
4. an in-memory fallback
|
|
47
|
+
|
|
48
|
+
So local development needs no setup at all. For production, set the two Upstash env vars and you are done:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
|
|
52
|
+
UPSTASH_REDIS_REST_TOKEN=your-token
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If a limiter ends up on the in-memory store while `NODE_ENV=production`, it prints a one-time warning, because per-instance limits across a multi-instance deploy are almost never what you want. Pass `silent: true` to suppress it when single-instance is intentional.
|
|
56
|
+
|
|
57
|
+
## Bring your own store
|
|
58
|
+
|
|
59
|
+
Already have a Redis client? Pass it. Anything with `incr`, `pexpire`, and `get` works, which covers both major clients with no adapter:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { Redis } from '@upstash/redis'
|
|
63
|
+
import { rateLimit } from 'ratefall'
|
|
64
|
+
|
|
65
|
+
const limiter = rateLimit({
|
|
66
|
+
limit: 100,
|
|
67
|
+
window: '1m',
|
|
68
|
+
redis: Redis.fromEnv(),
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import Redis from 'ioredis'
|
|
74
|
+
import { rateLimit } from 'ratefall'
|
|
75
|
+
|
|
76
|
+
const limiter = rateLimit({
|
|
77
|
+
limit: 100,
|
|
78
|
+
window: '1m',
|
|
79
|
+
redis: new Redis(process.env.REDIS_URL),
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Algorithms
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
rateLimit({ limit: 10, window: '10s', algorithm: 'fixed-window' }) // default
|
|
87
|
+
rateLimit({ limit: 10, window: '10s', algorithm: 'sliding-window' })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **`fixed-window`** counts requests inside contiguous windows. One counter per window, very cheap. Can allow up to 2x the limit across a boundary in the worst case.
|
|
91
|
+
- **`sliding-window`** weights the previous window by how far the current one has elapsed, smoothing out those boundary bursts. Two counters per identifier.
|
|
92
|
+
|
|
93
|
+
## Use in a Next.js route handler
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { rateLimit } from 'ratefall'
|
|
97
|
+
|
|
98
|
+
const limiter = rateLimit({ limit: 5, window: '1m' })
|
|
99
|
+
|
|
100
|
+
export async function POST(req: Request) {
|
|
101
|
+
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'
|
|
102
|
+
const { success, limit, remaining, reset } = await limiter.limit(ip)
|
|
103
|
+
|
|
104
|
+
if (!success) {
|
|
105
|
+
return new Response('Too many requests', {
|
|
106
|
+
status: 429,
|
|
107
|
+
headers: {
|
|
108
|
+
'RateLimit-Limit': String(limit),
|
|
109
|
+
'RateLimit-Remaining': String(remaining),
|
|
110
|
+
'RateLimit-Reset': String(Math.ceil((reset - Date.now()) / 1000)),
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ... handle the request
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## API
|
|
120
|
+
|
|
121
|
+
### `rateLimit(options)`
|
|
122
|
+
|
|
123
|
+
| Option | Type | Default | Description |
|
|
124
|
+
| --- | --- | --- | --- |
|
|
125
|
+
| `limit` | `number` | required | Max requests allowed per window. |
|
|
126
|
+
| `window` | `string \| number` | required | Window length. A number is milliseconds, or a string like `'10s'`, `'1m'`, `'2h'`, `'1d'`. |
|
|
127
|
+
| `algorithm` | `'fixed-window' \| 'sliding-window'` | `'fixed-window'` | Limiting algorithm. |
|
|
128
|
+
| `prefix` | `string` | `'ratefall'` | Key namespace, so multiple limiters can share one store. |
|
|
129
|
+
| `store` | `Store` | auto | An explicit store. Overrides everything else. |
|
|
130
|
+
| `redis` | `RedisLike` | auto | A Redis client. Overrides env auto-detection. |
|
|
131
|
+
| `silent` | `boolean` | `false` | Suppress the production in-memory warning. |
|
|
132
|
+
|
|
133
|
+
Returns a `RateLimiter` with one method:
|
|
134
|
+
|
|
135
|
+
### `limiter.limit(identifier)`
|
|
136
|
+
|
|
137
|
+
`identifier` is any string that scopes the limit: an IP, a user id, an API key. Returns a `RateLimitResult`:
|
|
138
|
+
|
|
139
|
+
| Field | Type | Description |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| `success` | `boolean` | Whether the request is allowed. |
|
|
142
|
+
| `limit` | `number` | The configured ceiling. |
|
|
143
|
+
| `remaining` | `number` | Requests left in the window (never below 0). |
|
|
144
|
+
| `reset` | `number` | Epoch milliseconds when the window resets. |
|
|
145
|
+
| `retryAfter` | `number` | Milliseconds until the next request is allowed. `0` when allowed. |
|
|
146
|
+
|
|
147
|
+
## How it compares
|
|
148
|
+
|
|
149
|
+
If you are all-in on Upstash and want every algorithm, analytics, and multi-region support, use [`@upstash/ratelimit`](https://github.com/upstash/ratelimit). `ratefall` is the smaller, zero-dependency option focused on one thing: the same limiter working from local dev to production without you wiring the store. It falls back to memory with no Redis at all, which `@upstash/ratelimit` does not.
|
|
150
|
+
|
|
151
|
+
## Notes
|
|
152
|
+
|
|
153
|
+
- The in-memory store is per-process. It is meant for local development and single-instance deploys, not shared production traffic.
|
|
154
|
+
- Redis operations use `incr` plus a conditional `pexpire`. A Lua-based single-round-trip path is on the roadmap.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/algorithms/fixed-window.ts
|
|
4
|
+
var fixedWindow = async (store, ctx, identifier, now) => {
|
|
5
|
+
const { limit, windowMs, prefix } = ctx;
|
|
6
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
7
|
+
const key = `${prefix}:${identifier}:${windowStart}`;
|
|
8
|
+
const count = await store.incr(key);
|
|
9
|
+
if (count === 1) await store.pexpire(key, windowMs);
|
|
10
|
+
const reset = windowStart + windowMs;
|
|
11
|
+
const success = count <= limit;
|
|
12
|
+
return {
|
|
13
|
+
success,
|
|
14
|
+
limit,
|
|
15
|
+
remaining: Math.max(0, limit - count),
|
|
16
|
+
reset,
|
|
17
|
+
retryAfter: success ? 0 : reset - now
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/algorithms/sliding-window.ts
|
|
22
|
+
var slidingWindow = async (store, ctx, identifier, now) => {
|
|
23
|
+
const { limit, windowMs, prefix } = ctx;
|
|
24
|
+
const currentStart = Math.floor(now / windowMs) * windowMs;
|
|
25
|
+
const previousStart = currentStart - windowMs;
|
|
26
|
+
const fraction = (now - currentStart) / windowMs;
|
|
27
|
+
const currentKey = `${prefix}:${identifier}:${currentStart}`;
|
|
28
|
+
const previousKey = `${prefix}:${identifier}:${previousStart}`;
|
|
29
|
+
const previousCount = await store.get(previousKey);
|
|
30
|
+
const currentCount = await store.incr(currentKey);
|
|
31
|
+
if (currentCount === 1) await store.pexpire(currentKey, windowMs * 2);
|
|
32
|
+
const weighted = previousCount * (1 - fraction) + currentCount;
|
|
33
|
+
const reset = currentStart + windowMs;
|
|
34
|
+
const success = weighted <= limit;
|
|
35
|
+
return {
|
|
36
|
+
success,
|
|
37
|
+
limit,
|
|
38
|
+
remaining: Math.max(0, Math.floor(limit - weighted)),
|
|
39
|
+
reset,
|
|
40
|
+
retryAfter: success ? 0 : reset - now
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/stores/memory.ts
|
|
45
|
+
var MemoryStore = class {
|
|
46
|
+
map = /* @__PURE__ */ new Map();
|
|
47
|
+
sweeper;
|
|
48
|
+
/** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */
|
|
49
|
+
constructor(sweepMs = 3e4) {
|
|
50
|
+
if (sweepMs > 0 && typeof setInterval === "function") {
|
|
51
|
+
this.sweeper = setInterval(() => this.sweep(), sweepMs);
|
|
52
|
+
this.sweeper.unref?.();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
live(key) {
|
|
56
|
+
const entry = this.map.get(key);
|
|
57
|
+
if (!entry) return void 0;
|
|
58
|
+
if (entry.expiresAt <= Date.now()) {
|
|
59
|
+
this.map.delete(key);
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
async incr(key) {
|
|
65
|
+
const entry = this.live(key);
|
|
66
|
+
if (!entry) {
|
|
67
|
+
this.map.set(key, { count: 1, expiresAt: Number.POSITIVE_INFINITY });
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
entry.count += 1;
|
|
71
|
+
return entry.count;
|
|
72
|
+
}
|
|
73
|
+
async pexpire(key, ms) {
|
|
74
|
+
const entry = this.live(key);
|
|
75
|
+
if (entry) entry.expiresAt = Date.now() + ms;
|
|
76
|
+
}
|
|
77
|
+
async get(key) {
|
|
78
|
+
return this.live(key)?.count ?? 0;
|
|
79
|
+
}
|
|
80
|
+
sweep() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
for (const [key, entry] of this.map) {
|
|
83
|
+
if (entry.expiresAt <= now) this.map.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Stop the background sweeper. Optional; the timer is unref'd. */
|
|
87
|
+
dispose() {
|
|
88
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/stores/redis.ts
|
|
93
|
+
var RedisStore = class {
|
|
94
|
+
constructor(client) {
|
|
95
|
+
this.client = client;
|
|
96
|
+
}
|
|
97
|
+
client;
|
|
98
|
+
async incr(key) {
|
|
99
|
+
return this.client.incr(key);
|
|
100
|
+
}
|
|
101
|
+
async pexpire(key, ms) {
|
|
102
|
+
await this.client.pexpire(key, ms);
|
|
103
|
+
}
|
|
104
|
+
async get(key) {
|
|
105
|
+
const value = await this.client.get(key);
|
|
106
|
+
if (value == null) return 0;
|
|
107
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
108
|
+
return Number.isFinite(n) ? n : 0;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/stores/upstash-rest.ts
|
|
113
|
+
var UpstashRestStore = class {
|
|
114
|
+
url;
|
|
115
|
+
token;
|
|
116
|
+
fetchImpl;
|
|
117
|
+
constructor(options) {
|
|
118
|
+
this.url = options.url.replace(/\/$/, "");
|
|
119
|
+
this.token = options.token;
|
|
120
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
121
|
+
if (typeof f !== "function") {
|
|
122
|
+
throw new Error("ratefall: no global fetch available; pass a `fetch` implementation to UpstashRestStore.");
|
|
123
|
+
}
|
|
124
|
+
this.fetchImpl = f;
|
|
125
|
+
}
|
|
126
|
+
async command(args) {
|
|
127
|
+
const res = await this.fetchImpl(this.url, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${this.token}`,
|
|
131
|
+
"Content-Type": "application/json"
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(args.map(String))
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
const detail = await res.text().catch(() => "");
|
|
137
|
+
throw new Error(`ratefall: Upstash REST error ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ""}`);
|
|
138
|
+
}
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
if (body.error) {
|
|
141
|
+
throw new Error(`ratefall: Upstash command failed - ${body.error}`);
|
|
142
|
+
}
|
|
143
|
+
return body.result;
|
|
144
|
+
}
|
|
145
|
+
async incr(key) {
|
|
146
|
+
return Number(await this.command(["INCR", key]));
|
|
147
|
+
}
|
|
148
|
+
async pexpire(key, ms) {
|
|
149
|
+
await this.command(["PEXPIRE", key, ms]);
|
|
150
|
+
}
|
|
151
|
+
async get(key) {
|
|
152
|
+
const result = await this.command(["GET", key]);
|
|
153
|
+
if (result == null) return 0;
|
|
154
|
+
const n = Number(result);
|
|
155
|
+
return Number.isFinite(n) ? n : 0;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/resolve-store.ts
|
|
160
|
+
function readEnv(key) {
|
|
161
|
+
const env = globalThis.process?.env;
|
|
162
|
+
return env?.[key];
|
|
163
|
+
}
|
|
164
|
+
function resolveStore(options) {
|
|
165
|
+
if (options.store) {
|
|
166
|
+
return { store: options.store, distributed: true, source: "custom store" };
|
|
167
|
+
}
|
|
168
|
+
if (options.redis) {
|
|
169
|
+
return { store: new RedisStore(options.redis), distributed: true, source: "redis client" };
|
|
170
|
+
}
|
|
171
|
+
const url = readEnv("UPSTASH_REDIS_REST_URL");
|
|
172
|
+
const token = readEnv("UPSTASH_REDIS_REST_TOKEN");
|
|
173
|
+
if (url && token) {
|
|
174
|
+
return { store: new UpstashRestStore({ url, token }), distributed: true, source: "Upstash REST (env)" };
|
|
175
|
+
}
|
|
176
|
+
return { store: new MemoryStore(), distributed: false, source: "in-memory" };
|
|
177
|
+
}
|
|
178
|
+
function isProduction() {
|
|
179
|
+
return readEnv("NODE_ENV") === "production";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/window.ts
|
|
183
|
+
var UNITS = {
|
|
184
|
+
ms: 1,
|
|
185
|
+
s: 1e3,
|
|
186
|
+
m: 6e4,
|
|
187
|
+
h: 36e5,
|
|
188
|
+
d: 864e5
|
|
189
|
+
};
|
|
190
|
+
var PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/;
|
|
191
|
+
function parseWindow(window) {
|
|
192
|
+
if (typeof window === "number") {
|
|
193
|
+
if (!Number.isFinite(window) || window <= 0) {
|
|
194
|
+
throw new RangeError(`ratefall: window must be a positive number of milliseconds, got ${window}`);
|
|
195
|
+
}
|
|
196
|
+
return Math.floor(window);
|
|
197
|
+
}
|
|
198
|
+
const match = PATTERN.exec(window.trim());
|
|
199
|
+
if (!match) {
|
|
200
|
+
throw new TypeError(`ratefall: invalid window "${window}". Use a number of ms or a string like "10s", "1m", "2h".`);
|
|
201
|
+
}
|
|
202
|
+
const value = Number(match[1]);
|
|
203
|
+
const unit = UNITS[match[2]];
|
|
204
|
+
const ms = Math.floor(value * unit);
|
|
205
|
+
if (ms <= 0) {
|
|
206
|
+
throw new RangeError(`ratefall: window must be greater than 0, got "${window}".`);
|
|
207
|
+
}
|
|
208
|
+
return ms;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/rate-limit.ts
|
|
212
|
+
var ALGORITHMS = {
|
|
213
|
+
"fixed-window": fixedWindow,
|
|
214
|
+
"sliding-window": slidingWindow
|
|
215
|
+
};
|
|
216
|
+
function rateLimit(options) {
|
|
217
|
+
const { limit, algorithm = "fixed-window", prefix = "ratefall", silent = false } = options;
|
|
218
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
219
|
+
throw new RangeError(`ratefall: limit must be a positive integer, got ${limit}`);
|
|
220
|
+
}
|
|
221
|
+
const run = ALGORITHMS[algorithm];
|
|
222
|
+
if (!run) {
|
|
223
|
+
throw new TypeError(`ratefall: unknown algorithm "${algorithm}". Use "fixed-window" or "sliding-window".`);
|
|
224
|
+
}
|
|
225
|
+
const windowMs = parseWindow(options.window);
|
|
226
|
+
const { store, distributed, source } = resolveStore(options);
|
|
227
|
+
if (!distributed && !silent && isProduction()) {
|
|
228
|
+
console.warn(
|
|
229
|
+
`ratefall: running with an in-memory store in production. Limits are per-instance and NOT shared. Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN or pass a \`redis\` client. Pass \`silent: true\` to hide this.`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const ctx = { limit, windowMs, prefix };
|
|
233
|
+
return {
|
|
234
|
+
async limit(identifier) {
|
|
235
|
+
if (!identifier) {
|
|
236
|
+
throw new TypeError("ratefall: identifier must be a non-empty string.");
|
|
237
|
+
}
|
|
238
|
+
return run(store, ctx, identifier, Date.now());
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
exports.MemoryStore = MemoryStore;
|
|
244
|
+
exports.RedisStore = RedisStore;
|
|
245
|
+
exports.UpstashRestStore = UpstashRestStore;
|
|
246
|
+
exports.parseWindow = parseWindow;
|
|
247
|
+
exports.rateLimit = rateLimit;
|
|
248
|
+
exports.resolveStore = resolveStore;
|
|
249
|
+
//# sourceMappingURL=index.cjs.map
|
|
250
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/algorithms/fixed-window.ts","../src/algorithms/sliding-window.ts","../src/stores/memory.ts","../src/stores/redis.ts","../src/stores/upstash-rest.ts","../src/resolve-store.ts","../src/window.ts","../src/rate-limit.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { AlgorithmFn } from './index.js'\n\n/**\n * Fixed-window counter. Divides time into contiguous windows of `windowMs` and\n * counts requests per window. Simple and cheap (one counter per window), at the\n * cost of allowing up to 2x `limit` across a window boundary in the worst case.\n */\nexport const fixedWindow: AlgorithmFn = async (store, ctx, identifier, now) => {\n const { limit, windowMs, prefix } = ctx\n const windowStart = Math.floor(now / windowMs) * windowMs\n const key = `${prefix}:${identifier}:${windowStart}`\n\n const count = await store.incr(key)\n if (count === 1) await store.pexpire(key, windowMs)\n\n const reset = windowStart + windowMs\n const success = count <= limit\n return {\n success,\n limit,\n remaining: Math.max(0, limit - count),\n reset,\n retryAfter: success ? 0 : reset - now,\n }\n}\n","import type { AlgorithmFn } from './index.js'\n\n/**\n * Sliding-window counter. Weights the previous window's count by how far the\n * current window has elapsed and adds the current count, smoothing out the\n * boundary bursts that fixed-window allows. Uses two counters per identifier.\n */\nexport const slidingWindow: AlgorithmFn = async (store, ctx, identifier, now) => {\n const { limit, windowMs, prefix } = ctx\n const currentStart = Math.floor(now / windowMs) * windowMs\n const previousStart = currentStart - windowMs\n const fraction = (now - currentStart) / windowMs // 0..1 through the current window\n\n const currentKey = `${prefix}:${identifier}:${currentStart}`\n const previousKey = `${prefix}:${identifier}:${previousStart}`\n\n const previousCount = await store.get(previousKey)\n const currentCount = await store.incr(currentKey)\n // Keep the counter alive long enough to still be read as \"previous\" next window.\n if (currentCount === 1) await store.pexpire(currentKey, windowMs * 2)\n\n const weighted = previousCount * (1 - fraction) + currentCount\n const reset = currentStart + windowMs\n const success = weighted <= limit\n return {\n success,\n limit,\n remaining: Math.max(0, Math.floor(limit - weighted)),\n reset,\n retryAfter: success ? 0 : reset - now,\n }\n}\n","import type { Store } from '../types.js'\n\ninterface Entry {\n count: number\n expiresAt: number\n}\n\n/**\n * In-process counter store. Used as the automatic fallback when no Redis is\n * configured. State is per-instance and not shared across processes, so it is\n * meant for local development and single-instance deployments, not distributed\n * production traffic.\n */\nexport class MemoryStore implements Store {\n private readonly map = new Map<string, Entry>()\n private readonly sweeper: ReturnType<typeof setInterval> | undefined\n\n /** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */\n constructor(sweepMs = 30_000) {\n if (sweepMs > 0 && typeof setInterval === 'function') {\n this.sweeper = setInterval(() => this.sweep(), sweepMs)\n // Never keep the event loop alive for cleanup alone.\n this.sweeper.unref?.()\n }\n }\n\n private live(key: string): Entry | undefined {\n const entry = this.map.get(key)\n if (!entry) return undefined\n if (entry.expiresAt <= Date.now()) {\n this.map.delete(key)\n return undefined\n }\n return entry\n }\n\n async incr(key: string): Promise<number> {\n const entry = this.live(key)\n if (!entry) {\n // Default to no expiry; pexpire is expected to follow on the first hit.\n this.map.set(key, { count: 1, expiresAt: Number.POSITIVE_INFINITY })\n return 1\n }\n entry.count += 1\n return entry.count\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n const entry = this.live(key)\n if (entry) entry.expiresAt = Date.now() + ms\n }\n\n async get(key: string): Promise<number> {\n return this.live(key)?.count ?? 0\n }\n\n private sweep(): void {\n const now = Date.now()\n for (const [key, entry] of this.map) {\n if (entry.expiresAt <= now) this.map.delete(key)\n }\n }\n\n /** Stop the background sweeper. Optional; the timer is unref'd. */\n dispose(): void {\n if (this.sweeper) clearInterval(this.sweeper)\n }\n}\n","import type { RedisLike, Store } from '../types.js'\n\n/**\n * Adapts any Redis client with `incr` / `pexpire` / `get` (both `@upstash/redis`\n * and `ioredis` qualify) to the {@link Store} interface.\n */\nexport class RedisStore implements Store {\n constructor(private readonly client: RedisLike) {}\n\n async incr(key: string): Promise<number> {\n return this.client.incr(key)\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n await this.client.pexpire(key, ms)\n }\n\n async get(key: string): Promise<number> {\n const value = await this.client.get(key)\n if (value == null) return 0\n const n = typeof value === 'number' ? value : Number(value)\n return Number.isFinite(n) ? n : 0\n }\n}\n","import type { Store } from '../types.js'\n\nexport interface UpstashRestOptions {\n url: string\n token: string\n /** Custom fetch implementation. Defaults to the global `fetch`. */\n fetch?: typeof fetch\n}\n\n/**\n * Zero-dependency store backed by the Upstash Redis REST API. Uses `fetch`, so\n * it runs anywhere the runtime provides one (Node 18+, edge, workers) without\n * pulling in a Redis client. This is what env auto-detection constructs.\n */\nexport class UpstashRestStore implements Store {\n private readonly url: string\n private readonly token: string\n private readonly fetchImpl: typeof fetch\n\n constructor(options: UpstashRestOptions) {\n this.url = options.url.replace(/\\/$/, '')\n this.token = options.token\n const f = options.fetch ?? globalThis.fetch\n if (typeof f !== 'function') {\n throw new Error('ratefall: no global fetch available; pass a `fetch` implementation to UpstashRestStore.')\n }\n this.fetchImpl = f\n }\n\n private async command(args: (string | number)[]): Promise<unknown> {\n const res = await this.fetchImpl(this.url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(args.map(String)),\n })\n if (!res.ok) {\n const detail = await res.text().catch(() => '')\n throw new Error(`ratefall: Upstash REST error ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ''}`)\n }\n const body = (await res.json()) as { result?: unknown; error?: string }\n if (body.error) {\n throw new Error(`ratefall: Upstash command failed - ${body.error}`)\n }\n return body.result\n }\n\n async incr(key: string): Promise<number> {\n return Number(await this.command(['INCR', key]))\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n await this.command(['PEXPIRE', key, ms])\n }\n\n async get(key: string): Promise<number> {\n const result = await this.command(['GET', key])\n if (result == null) return 0\n const n = Number(result)\n return Number.isFinite(n) ? n : 0\n }\n}\n","import type { RateLimitOptions, Store } from './types.js'\nimport { MemoryStore } from './stores/memory.js'\nimport { RedisStore } from './stores/redis.js'\nimport { UpstashRestStore } from './stores/upstash-rest.js'\n\nexport interface ResolvedStore {\n store: Store\n /** Whether the store is shared across processes (Redis) vs per-instance (memory). */\n distributed: boolean\n /** Human-readable source, for the dev warning. */\n source: string\n}\n\nfunction readEnv(key: string): string | undefined {\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env\n return env?.[key]\n}\n\n/**\n * Decide which store to use, in priority order:\n * 1. an explicit `store`\n * 2. an explicit `redis` client\n * 3. `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` in the environment\n * 4. in-memory fallback\n */\nexport function resolveStore(options: RateLimitOptions): ResolvedStore {\n if (options.store) {\n return { store: options.store, distributed: true, source: 'custom store' }\n }\n\n if (options.redis) {\n return { store: new RedisStore(options.redis), distributed: true, source: 'redis client' }\n }\n\n const url = readEnv('UPSTASH_REDIS_REST_URL')\n const token = readEnv('UPSTASH_REDIS_REST_TOKEN')\n if (url && token) {\n return { store: new UpstashRestStore({ url, token }), distributed: true, source: 'Upstash REST (env)' }\n }\n\n return { store: new MemoryStore(), distributed: false, source: 'in-memory' }\n}\n\n/** True when the runtime looks like a production deployment. */\nexport function isProduction(): boolean {\n return readEnv('NODE_ENV') === 'production'\n}\n","const UNITS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n}\n\nconst PATTERN = /^(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d)$/\n\n/**\n * Normalize a window into milliseconds. Accepts a raw millisecond number or a\n * duration string like `'500ms'`, `'10s'`, `'1m'`, `'2h'`, `'1d'`.\n */\nexport function parseWindow(window: string | number): number {\n if (typeof window === 'number') {\n if (!Number.isFinite(window) || window <= 0) {\n throw new RangeError(`ratefall: window must be a positive number of milliseconds, got ${window}`)\n }\n return Math.floor(window)\n }\n\n const match = PATTERN.exec(window.trim())\n if (!match) {\n throw new TypeError(`ratefall: invalid window \"${window}\". Use a number of ms or a string like \"10s\", \"1m\", \"2h\".`)\n }\n\n const value = Number(match[1])\n const unit = UNITS[match[2] as string] as number\n const ms = Math.floor(value * unit)\n if (ms <= 0) {\n throw new RangeError(`ratefall: window must be greater than 0, got \"${window}\".`)\n }\n return ms\n}\n","import type { AlgorithmContext, AlgorithmFn } from './algorithms/index.js'\nimport { fixedWindow } from './algorithms/fixed-window.js'\nimport { slidingWindow } from './algorithms/sliding-window.js'\nimport { isProduction, resolveStore } from './resolve-store.js'\nimport type { RateLimiter, RateLimitOptions } from './types.js'\nimport { parseWindow } from './window.js'\n\nconst ALGORITHMS: Record<string, AlgorithmFn> = {\n 'fixed-window': fixedWindow,\n 'sliding-window': slidingWindow,\n}\n\n/**\n * Create a rate limiter. With no store configured it uses an in-memory counter\n * (great for local dev); set `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`,\n * or pass a `redis` client, and the exact same code becomes distributed.\n *\n * @example\n * const limiter = rateLimit({ limit: 10, window: '10s' })\n * const { success } = await limiter.limit(userId)\n * if (!success) return new Response('slow down', { status: 429 })\n */\nexport function rateLimit(options: RateLimitOptions): RateLimiter {\n const { limit, algorithm = 'fixed-window', prefix = 'ratefall', silent = false } = options\n\n if (!Number.isInteger(limit) || limit <= 0) {\n throw new RangeError(`ratefall: limit must be a positive integer, got ${limit}`)\n }\n\n const run = ALGORITHMS[algorithm]\n if (!run) {\n throw new TypeError(`ratefall: unknown algorithm \"${algorithm}\". Use \"fixed-window\" or \"sliding-window\".`)\n }\n\n const windowMs = parseWindow(options.window)\n const { store, distributed, source } = resolveStore(options)\n\n if (!distributed && !silent && isProduction()) {\n // Not an error: single-instance prod is legitimate. But un-shared limits in a\n // multi-instance deploy are a footgun, so make it loud exactly once.\n console.warn(\n `ratefall: running with an in-memory store in production. Limits are per-instance and NOT shared. ` +\n `Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN or pass a \\`redis\\` client. Pass \\`silent: true\\` to hide this.`,\n )\n }\n\n const ctx: AlgorithmContext = { limit, windowMs, prefix }\n\n return {\n async limit(identifier: string) {\n if (!identifier) {\n throw new TypeError('ratefall: identifier must be a non-empty string.')\n }\n return run(store, ctx, identifier, Date.now())\n },\n }\n}\n\n/** The resolved store source, exposed for debugging/tests. */\nexport { resolveStore } from './resolve-store.js'\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Result of a single rate-limit check. */
|
|
2
|
+
interface RateLimitResult {
|
|
3
|
+
/** Whether the request is allowed. */
|
|
4
|
+
success: boolean;
|
|
5
|
+
/** The configured request ceiling for the window. */
|
|
6
|
+
limit: number;
|
|
7
|
+
/** Requests remaining in the current window (never below 0). */
|
|
8
|
+
remaining: number;
|
|
9
|
+
/** Epoch milliseconds at which the window resets. */
|
|
10
|
+
reset: number;
|
|
11
|
+
/** Milliseconds until the next request would be allowed. 0 when `success` is true. */
|
|
12
|
+
retryAfter: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Minimal counter backend. Mirrors the handful of Redis commands the
|
|
16
|
+
* algorithms need, so any store (memory, Upstash REST, a duck-typed Redis
|
|
17
|
+
* client) can satisfy it.
|
|
18
|
+
*/
|
|
19
|
+
interface Store {
|
|
20
|
+
/** Increment the counter at `key`, returning the new value. */
|
|
21
|
+
incr(key: string): Promise<number>;
|
|
22
|
+
/** Set the time-to-live of `key` in milliseconds. */
|
|
23
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
24
|
+
/** Read the counter at `key`. Returns 0 when the key is absent or expired. */
|
|
25
|
+
get(key: string): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The subset of a Redis client `ratefall` uses. Both `@upstash/redis` and
|
|
29
|
+
* `ioredis` satisfy this shape, so you can pass either without an adapter.
|
|
30
|
+
*/
|
|
31
|
+
interface RedisLike {
|
|
32
|
+
incr(key: string): Promise<number>;
|
|
33
|
+
pexpire(key: string, ms: number): Promise<unknown>;
|
|
34
|
+
get(key: string): Promise<string | number | null>;
|
|
35
|
+
}
|
|
36
|
+
type Algorithm = 'fixed-window' | 'sliding-window';
|
|
37
|
+
interface RateLimitOptions {
|
|
38
|
+
/** Maximum number of requests allowed per window. */
|
|
39
|
+
limit: number;
|
|
40
|
+
/** Window length. A number is milliseconds, or a duration string like `'10s'`, `'1m'`, `'2h'`. */
|
|
41
|
+
window: string | number;
|
|
42
|
+
/** Limiting algorithm. Defaults to `'fixed-window'`. */
|
|
43
|
+
algorithm?: Algorithm;
|
|
44
|
+
/** Key namespace, so multiple limiters can share a store. Defaults to `'ratefall'`. */
|
|
45
|
+
prefix?: string;
|
|
46
|
+
/** An explicit store. Overrides `redis` and env auto-detection. */
|
|
47
|
+
store?: Store;
|
|
48
|
+
/** A Redis client (`@upstash/redis`, `ioredis`, ...). Overrides env auto-detection. */
|
|
49
|
+
redis?: RedisLike;
|
|
50
|
+
/** Suppress the "no distributed store in production" warning. */
|
|
51
|
+
silent?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** A configured rate limiter. */
|
|
54
|
+
interface RateLimiter {
|
|
55
|
+
/** Check and consume one request for `identifier` (an IP, user id, API key, ...). */
|
|
56
|
+
limit(identifier: string): Promise<RateLimitResult>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ResolvedStore {
|
|
60
|
+
store: Store;
|
|
61
|
+
/** Whether the store is shared across processes (Redis) vs per-instance (memory). */
|
|
62
|
+
distributed: boolean;
|
|
63
|
+
/** Human-readable source, for the dev warning. */
|
|
64
|
+
source: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Decide which store to use, in priority order:
|
|
68
|
+
* 1. an explicit `store`
|
|
69
|
+
* 2. an explicit `redis` client
|
|
70
|
+
* 3. `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` in the environment
|
|
71
|
+
* 4. in-memory fallback
|
|
72
|
+
*/
|
|
73
|
+
declare function resolveStore(options: RateLimitOptions): ResolvedStore;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a rate limiter. With no store configured it uses an in-memory counter
|
|
77
|
+
* (great for local dev); set `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`,
|
|
78
|
+
* or pass a `redis` client, and the exact same code becomes distributed.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const limiter = rateLimit({ limit: 10, window: '10s' })
|
|
82
|
+
* const { success } = await limiter.limit(userId)
|
|
83
|
+
* if (!success) return new Response('slow down', { status: 429 })
|
|
84
|
+
*/
|
|
85
|
+
declare function rateLimit(options: RateLimitOptions): RateLimiter;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* In-process counter store. Used as the automatic fallback when no Redis is
|
|
89
|
+
* configured. State is per-instance and not shared across processes, so it is
|
|
90
|
+
* meant for local development and single-instance deployments, not distributed
|
|
91
|
+
* production traffic.
|
|
92
|
+
*/
|
|
93
|
+
declare class MemoryStore implements Store {
|
|
94
|
+
private readonly map;
|
|
95
|
+
private readonly sweeper;
|
|
96
|
+
/** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */
|
|
97
|
+
constructor(sweepMs?: number);
|
|
98
|
+
private live;
|
|
99
|
+
incr(key: string): Promise<number>;
|
|
100
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
101
|
+
get(key: string): Promise<number>;
|
|
102
|
+
private sweep;
|
|
103
|
+
/** Stop the background sweeper. Optional; the timer is unref'd. */
|
|
104
|
+
dispose(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adapts any Redis client with `incr` / `pexpire` / `get` (both `@upstash/redis`
|
|
109
|
+
* and `ioredis` qualify) to the {@link Store} interface.
|
|
110
|
+
*/
|
|
111
|
+
declare class RedisStore implements Store {
|
|
112
|
+
private readonly client;
|
|
113
|
+
constructor(client: RedisLike);
|
|
114
|
+
incr(key: string): Promise<number>;
|
|
115
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
116
|
+
get(key: string): Promise<number>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface UpstashRestOptions {
|
|
120
|
+
url: string;
|
|
121
|
+
token: string;
|
|
122
|
+
/** Custom fetch implementation. Defaults to the global `fetch`. */
|
|
123
|
+
fetch?: typeof fetch;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Zero-dependency store backed by the Upstash Redis REST API. Uses `fetch`, so
|
|
127
|
+
* it runs anywhere the runtime provides one (Node 18+, edge, workers) without
|
|
128
|
+
* pulling in a Redis client. This is what env auto-detection constructs.
|
|
129
|
+
*/
|
|
130
|
+
declare class UpstashRestStore implements Store {
|
|
131
|
+
private readonly url;
|
|
132
|
+
private readonly token;
|
|
133
|
+
private readonly fetchImpl;
|
|
134
|
+
constructor(options: UpstashRestOptions);
|
|
135
|
+
private command;
|
|
136
|
+
incr(key: string): Promise<number>;
|
|
137
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
138
|
+
get(key: string): Promise<number>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalize a window into milliseconds. Accepts a raw millisecond number or a
|
|
143
|
+
* duration string like `'500ms'`, `'10s'`, `'1m'`, `'2h'`, `'1d'`.
|
|
144
|
+
*/
|
|
145
|
+
declare function parseWindow(window: string | number): number;
|
|
146
|
+
|
|
147
|
+
export { type Algorithm, MemoryStore, type RateLimitOptions, type RateLimitResult, type RateLimiter, type RedisLike, RedisStore, type Store, type UpstashRestOptions, UpstashRestStore, parseWindow, rateLimit, resolveStore };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Result of a single rate-limit check. */
|
|
2
|
+
interface RateLimitResult {
|
|
3
|
+
/** Whether the request is allowed. */
|
|
4
|
+
success: boolean;
|
|
5
|
+
/** The configured request ceiling for the window. */
|
|
6
|
+
limit: number;
|
|
7
|
+
/** Requests remaining in the current window (never below 0). */
|
|
8
|
+
remaining: number;
|
|
9
|
+
/** Epoch milliseconds at which the window resets. */
|
|
10
|
+
reset: number;
|
|
11
|
+
/** Milliseconds until the next request would be allowed. 0 when `success` is true. */
|
|
12
|
+
retryAfter: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Minimal counter backend. Mirrors the handful of Redis commands the
|
|
16
|
+
* algorithms need, so any store (memory, Upstash REST, a duck-typed Redis
|
|
17
|
+
* client) can satisfy it.
|
|
18
|
+
*/
|
|
19
|
+
interface Store {
|
|
20
|
+
/** Increment the counter at `key`, returning the new value. */
|
|
21
|
+
incr(key: string): Promise<number>;
|
|
22
|
+
/** Set the time-to-live of `key` in milliseconds. */
|
|
23
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
24
|
+
/** Read the counter at `key`. Returns 0 when the key is absent or expired. */
|
|
25
|
+
get(key: string): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The subset of a Redis client `ratefall` uses. Both `@upstash/redis` and
|
|
29
|
+
* `ioredis` satisfy this shape, so you can pass either without an adapter.
|
|
30
|
+
*/
|
|
31
|
+
interface RedisLike {
|
|
32
|
+
incr(key: string): Promise<number>;
|
|
33
|
+
pexpire(key: string, ms: number): Promise<unknown>;
|
|
34
|
+
get(key: string): Promise<string | number | null>;
|
|
35
|
+
}
|
|
36
|
+
type Algorithm = 'fixed-window' | 'sliding-window';
|
|
37
|
+
interface RateLimitOptions {
|
|
38
|
+
/** Maximum number of requests allowed per window. */
|
|
39
|
+
limit: number;
|
|
40
|
+
/** Window length. A number is milliseconds, or a duration string like `'10s'`, `'1m'`, `'2h'`. */
|
|
41
|
+
window: string | number;
|
|
42
|
+
/** Limiting algorithm. Defaults to `'fixed-window'`. */
|
|
43
|
+
algorithm?: Algorithm;
|
|
44
|
+
/** Key namespace, so multiple limiters can share a store. Defaults to `'ratefall'`. */
|
|
45
|
+
prefix?: string;
|
|
46
|
+
/** An explicit store. Overrides `redis` and env auto-detection. */
|
|
47
|
+
store?: Store;
|
|
48
|
+
/** A Redis client (`@upstash/redis`, `ioredis`, ...). Overrides env auto-detection. */
|
|
49
|
+
redis?: RedisLike;
|
|
50
|
+
/** Suppress the "no distributed store in production" warning. */
|
|
51
|
+
silent?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** A configured rate limiter. */
|
|
54
|
+
interface RateLimiter {
|
|
55
|
+
/** Check and consume one request for `identifier` (an IP, user id, API key, ...). */
|
|
56
|
+
limit(identifier: string): Promise<RateLimitResult>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ResolvedStore {
|
|
60
|
+
store: Store;
|
|
61
|
+
/** Whether the store is shared across processes (Redis) vs per-instance (memory). */
|
|
62
|
+
distributed: boolean;
|
|
63
|
+
/** Human-readable source, for the dev warning. */
|
|
64
|
+
source: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Decide which store to use, in priority order:
|
|
68
|
+
* 1. an explicit `store`
|
|
69
|
+
* 2. an explicit `redis` client
|
|
70
|
+
* 3. `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` in the environment
|
|
71
|
+
* 4. in-memory fallback
|
|
72
|
+
*/
|
|
73
|
+
declare function resolveStore(options: RateLimitOptions): ResolvedStore;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a rate limiter. With no store configured it uses an in-memory counter
|
|
77
|
+
* (great for local dev); set `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`,
|
|
78
|
+
* or pass a `redis` client, and the exact same code becomes distributed.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const limiter = rateLimit({ limit: 10, window: '10s' })
|
|
82
|
+
* const { success } = await limiter.limit(userId)
|
|
83
|
+
* if (!success) return new Response('slow down', { status: 429 })
|
|
84
|
+
*/
|
|
85
|
+
declare function rateLimit(options: RateLimitOptions): RateLimiter;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* In-process counter store. Used as the automatic fallback when no Redis is
|
|
89
|
+
* configured. State is per-instance and not shared across processes, so it is
|
|
90
|
+
* meant for local development and single-instance deployments, not distributed
|
|
91
|
+
* production traffic.
|
|
92
|
+
*/
|
|
93
|
+
declare class MemoryStore implements Store {
|
|
94
|
+
private readonly map;
|
|
95
|
+
private readonly sweeper;
|
|
96
|
+
/** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */
|
|
97
|
+
constructor(sweepMs?: number);
|
|
98
|
+
private live;
|
|
99
|
+
incr(key: string): Promise<number>;
|
|
100
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
101
|
+
get(key: string): Promise<number>;
|
|
102
|
+
private sweep;
|
|
103
|
+
/** Stop the background sweeper. Optional; the timer is unref'd. */
|
|
104
|
+
dispose(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adapts any Redis client with `incr` / `pexpire` / `get` (both `@upstash/redis`
|
|
109
|
+
* and `ioredis` qualify) to the {@link Store} interface.
|
|
110
|
+
*/
|
|
111
|
+
declare class RedisStore implements Store {
|
|
112
|
+
private readonly client;
|
|
113
|
+
constructor(client: RedisLike);
|
|
114
|
+
incr(key: string): Promise<number>;
|
|
115
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
116
|
+
get(key: string): Promise<number>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface UpstashRestOptions {
|
|
120
|
+
url: string;
|
|
121
|
+
token: string;
|
|
122
|
+
/** Custom fetch implementation. Defaults to the global `fetch`. */
|
|
123
|
+
fetch?: typeof fetch;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Zero-dependency store backed by the Upstash Redis REST API. Uses `fetch`, so
|
|
127
|
+
* it runs anywhere the runtime provides one (Node 18+, edge, workers) without
|
|
128
|
+
* pulling in a Redis client. This is what env auto-detection constructs.
|
|
129
|
+
*/
|
|
130
|
+
declare class UpstashRestStore implements Store {
|
|
131
|
+
private readonly url;
|
|
132
|
+
private readonly token;
|
|
133
|
+
private readonly fetchImpl;
|
|
134
|
+
constructor(options: UpstashRestOptions);
|
|
135
|
+
private command;
|
|
136
|
+
incr(key: string): Promise<number>;
|
|
137
|
+
pexpire(key: string, ms: number): Promise<void>;
|
|
138
|
+
get(key: string): Promise<number>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalize a window into milliseconds. Accepts a raw millisecond number or a
|
|
143
|
+
* duration string like `'500ms'`, `'10s'`, `'1m'`, `'2h'`, `'1d'`.
|
|
144
|
+
*/
|
|
145
|
+
declare function parseWindow(window: string | number): number;
|
|
146
|
+
|
|
147
|
+
export { type Algorithm, MemoryStore, type RateLimitOptions, type RateLimitResult, type RateLimiter, type RedisLike, RedisStore, type Store, type UpstashRestOptions, UpstashRestStore, parseWindow, rateLimit, resolveStore };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// src/algorithms/fixed-window.ts
|
|
2
|
+
var fixedWindow = async (store, ctx, identifier, now) => {
|
|
3
|
+
const { limit, windowMs, prefix } = ctx;
|
|
4
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
5
|
+
const key = `${prefix}:${identifier}:${windowStart}`;
|
|
6
|
+
const count = await store.incr(key);
|
|
7
|
+
if (count === 1) await store.pexpire(key, windowMs);
|
|
8
|
+
const reset = windowStart + windowMs;
|
|
9
|
+
const success = count <= limit;
|
|
10
|
+
return {
|
|
11
|
+
success,
|
|
12
|
+
limit,
|
|
13
|
+
remaining: Math.max(0, limit - count),
|
|
14
|
+
reset,
|
|
15
|
+
retryAfter: success ? 0 : reset - now
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/algorithms/sliding-window.ts
|
|
20
|
+
var slidingWindow = async (store, ctx, identifier, now) => {
|
|
21
|
+
const { limit, windowMs, prefix } = ctx;
|
|
22
|
+
const currentStart = Math.floor(now / windowMs) * windowMs;
|
|
23
|
+
const previousStart = currentStart - windowMs;
|
|
24
|
+
const fraction = (now - currentStart) / windowMs;
|
|
25
|
+
const currentKey = `${prefix}:${identifier}:${currentStart}`;
|
|
26
|
+
const previousKey = `${prefix}:${identifier}:${previousStart}`;
|
|
27
|
+
const previousCount = await store.get(previousKey);
|
|
28
|
+
const currentCount = await store.incr(currentKey);
|
|
29
|
+
if (currentCount === 1) await store.pexpire(currentKey, windowMs * 2);
|
|
30
|
+
const weighted = previousCount * (1 - fraction) + currentCount;
|
|
31
|
+
const reset = currentStart + windowMs;
|
|
32
|
+
const success = weighted <= limit;
|
|
33
|
+
return {
|
|
34
|
+
success,
|
|
35
|
+
limit,
|
|
36
|
+
remaining: Math.max(0, Math.floor(limit - weighted)),
|
|
37
|
+
reset,
|
|
38
|
+
retryAfter: success ? 0 : reset - now
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/stores/memory.ts
|
|
43
|
+
var MemoryStore = class {
|
|
44
|
+
map = /* @__PURE__ */ new Map();
|
|
45
|
+
sweeper;
|
|
46
|
+
/** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */
|
|
47
|
+
constructor(sweepMs = 3e4) {
|
|
48
|
+
if (sweepMs > 0 && typeof setInterval === "function") {
|
|
49
|
+
this.sweeper = setInterval(() => this.sweep(), sweepMs);
|
|
50
|
+
this.sweeper.unref?.();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
live(key) {
|
|
54
|
+
const entry = this.map.get(key);
|
|
55
|
+
if (!entry) return void 0;
|
|
56
|
+
if (entry.expiresAt <= Date.now()) {
|
|
57
|
+
this.map.delete(key);
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
async incr(key) {
|
|
63
|
+
const entry = this.live(key);
|
|
64
|
+
if (!entry) {
|
|
65
|
+
this.map.set(key, { count: 1, expiresAt: Number.POSITIVE_INFINITY });
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
entry.count += 1;
|
|
69
|
+
return entry.count;
|
|
70
|
+
}
|
|
71
|
+
async pexpire(key, ms) {
|
|
72
|
+
const entry = this.live(key);
|
|
73
|
+
if (entry) entry.expiresAt = Date.now() + ms;
|
|
74
|
+
}
|
|
75
|
+
async get(key) {
|
|
76
|
+
return this.live(key)?.count ?? 0;
|
|
77
|
+
}
|
|
78
|
+
sweep() {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
for (const [key, entry] of this.map) {
|
|
81
|
+
if (entry.expiresAt <= now) this.map.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Stop the background sweeper. Optional; the timer is unref'd. */
|
|
85
|
+
dispose() {
|
|
86
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/stores/redis.ts
|
|
91
|
+
var RedisStore = class {
|
|
92
|
+
constructor(client) {
|
|
93
|
+
this.client = client;
|
|
94
|
+
}
|
|
95
|
+
client;
|
|
96
|
+
async incr(key) {
|
|
97
|
+
return this.client.incr(key);
|
|
98
|
+
}
|
|
99
|
+
async pexpire(key, ms) {
|
|
100
|
+
await this.client.pexpire(key, ms);
|
|
101
|
+
}
|
|
102
|
+
async get(key) {
|
|
103
|
+
const value = await this.client.get(key);
|
|
104
|
+
if (value == null) return 0;
|
|
105
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
106
|
+
return Number.isFinite(n) ? n : 0;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/stores/upstash-rest.ts
|
|
111
|
+
var UpstashRestStore = class {
|
|
112
|
+
url;
|
|
113
|
+
token;
|
|
114
|
+
fetchImpl;
|
|
115
|
+
constructor(options) {
|
|
116
|
+
this.url = options.url.replace(/\/$/, "");
|
|
117
|
+
this.token = options.token;
|
|
118
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
119
|
+
if (typeof f !== "function") {
|
|
120
|
+
throw new Error("ratefall: no global fetch available; pass a `fetch` implementation to UpstashRestStore.");
|
|
121
|
+
}
|
|
122
|
+
this.fetchImpl = f;
|
|
123
|
+
}
|
|
124
|
+
async command(args) {
|
|
125
|
+
const res = await this.fetchImpl(this.url, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
Authorization: `Bearer ${this.token}`,
|
|
129
|
+
"Content-Type": "application/json"
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(args.map(String))
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
const detail = await res.text().catch(() => "");
|
|
135
|
+
throw new Error(`ratefall: Upstash REST error ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ""}`);
|
|
136
|
+
}
|
|
137
|
+
const body = await res.json();
|
|
138
|
+
if (body.error) {
|
|
139
|
+
throw new Error(`ratefall: Upstash command failed - ${body.error}`);
|
|
140
|
+
}
|
|
141
|
+
return body.result;
|
|
142
|
+
}
|
|
143
|
+
async incr(key) {
|
|
144
|
+
return Number(await this.command(["INCR", key]));
|
|
145
|
+
}
|
|
146
|
+
async pexpire(key, ms) {
|
|
147
|
+
await this.command(["PEXPIRE", key, ms]);
|
|
148
|
+
}
|
|
149
|
+
async get(key) {
|
|
150
|
+
const result = await this.command(["GET", key]);
|
|
151
|
+
if (result == null) return 0;
|
|
152
|
+
const n = Number(result);
|
|
153
|
+
return Number.isFinite(n) ? n : 0;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// src/resolve-store.ts
|
|
158
|
+
function readEnv(key) {
|
|
159
|
+
const env = globalThis.process?.env;
|
|
160
|
+
return env?.[key];
|
|
161
|
+
}
|
|
162
|
+
function resolveStore(options) {
|
|
163
|
+
if (options.store) {
|
|
164
|
+
return { store: options.store, distributed: true, source: "custom store" };
|
|
165
|
+
}
|
|
166
|
+
if (options.redis) {
|
|
167
|
+
return { store: new RedisStore(options.redis), distributed: true, source: "redis client" };
|
|
168
|
+
}
|
|
169
|
+
const url = readEnv("UPSTASH_REDIS_REST_URL");
|
|
170
|
+
const token = readEnv("UPSTASH_REDIS_REST_TOKEN");
|
|
171
|
+
if (url && token) {
|
|
172
|
+
return { store: new UpstashRestStore({ url, token }), distributed: true, source: "Upstash REST (env)" };
|
|
173
|
+
}
|
|
174
|
+
return { store: new MemoryStore(), distributed: false, source: "in-memory" };
|
|
175
|
+
}
|
|
176
|
+
function isProduction() {
|
|
177
|
+
return readEnv("NODE_ENV") === "production";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/window.ts
|
|
181
|
+
var UNITS = {
|
|
182
|
+
ms: 1,
|
|
183
|
+
s: 1e3,
|
|
184
|
+
m: 6e4,
|
|
185
|
+
h: 36e5,
|
|
186
|
+
d: 864e5
|
|
187
|
+
};
|
|
188
|
+
var PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/;
|
|
189
|
+
function parseWindow(window) {
|
|
190
|
+
if (typeof window === "number") {
|
|
191
|
+
if (!Number.isFinite(window) || window <= 0) {
|
|
192
|
+
throw new RangeError(`ratefall: window must be a positive number of milliseconds, got ${window}`);
|
|
193
|
+
}
|
|
194
|
+
return Math.floor(window);
|
|
195
|
+
}
|
|
196
|
+
const match = PATTERN.exec(window.trim());
|
|
197
|
+
if (!match) {
|
|
198
|
+
throw new TypeError(`ratefall: invalid window "${window}". Use a number of ms or a string like "10s", "1m", "2h".`);
|
|
199
|
+
}
|
|
200
|
+
const value = Number(match[1]);
|
|
201
|
+
const unit = UNITS[match[2]];
|
|
202
|
+
const ms = Math.floor(value * unit);
|
|
203
|
+
if (ms <= 0) {
|
|
204
|
+
throw new RangeError(`ratefall: window must be greater than 0, got "${window}".`);
|
|
205
|
+
}
|
|
206
|
+
return ms;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/rate-limit.ts
|
|
210
|
+
var ALGORITHMS = {
|
|
211
|
+
"fixed-window": fixedWindow,
|
|
212
|
+
"sliding-window": slidingWindow
|
|
213
|
+
};
|
|
214
|
+
function rateLimit(options) {
|
|
215
|
+
const { limit, algorithm = "fixed-window", prefix = "ratefall", silent = false } = options;
|
|
216
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
217
|
+
throw new RangeError(`ratefall: limit must be a positive integer, got ${limit}`);
|
|
218
|
+
}
|
|
219
|
+
const run = ALGORITHMS[algorithm];
|
|
220
|
+
if (!run) {
|
|
221
|
+
throw new TypeError(`ratefall: unknown algorithm "${algorithm}". Use "fixed-window" or "sliding-window".`);
|
|
222
|
+
}
|
|
223
|
+
const windowMs = parseWindow(options.window);
|
|
224
|
+
const { store, distributed, source } = resolveStore(options);
|
|
225
|
+
if (!distributed && !silent && isProduction()) {
|
|
226
|
+
console.warn(
|
|
227
|
+
`ratefall: running with an in-memory store in production. Limits are per-instance and NOT shared. Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN or pass a \`redis\` client. Pass \`silent: true\` to hide this.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const ctx = { limit, windowMs, prefix };
|
|
231
|
+
return {
|
|
232
|
+
async limit(identifier) {
|
|
233
|
+
if (!identifier) {
|
|
234
|
+
throw new TypeError("ratefall: identifier must be a non-empty string.");
|
|
235
|
+
}
|
|
236
|
+
return run(store, ctx, identifier, Date.now());
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export { MemoryStore, RedisStore, UpstashRestStore, parseWindow, rateLimit, resolveStore };
|
|
242
|
+
//# sourceMappingURL=index.js.map
|
|
243
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/algorithms/fixed-window.ts","../src/algorithms/sliding-window.ts","../src/stores/memory.ts","../src/stores/redis.ts","../src/stores/upstash-rest.ts","../src/resolve-store.ts","../src/window.ts","../src/rate-limit.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { AlgorithmFn } from './index.js'\n\n/**\n * Fixed-window counter. Divides time into contiguous windows of `windowMs` and\n * counts requests per window. Simple and cheap (one counter per window), at the\n * cost of allowing up to 2x `limit` across a window boundary in the worst case.\n */\nexport const fixedWindow: AlgorithmFn = async (store, ctx, identifier, now) => {\n const { limit, windowMs, prefix } = ctx\n const windowStart = Math.floor(now / windowMs) * windowMs\n const key = `${prefix}:${identifier}:${windowStart}`\n\n const count = await store.incr(key)\n if (count === 1) await store.pexpire(key, windowMs)\n\n const reset = windowStart + windowMs\n const success = count <= limit\n return {\n success,\n limit,\n remaining: Math.max(0, limit - count),\n reset,\n retryAfter: success ? 0 : reset - now,\n }\n}\n","import type { AlgorithmFn } from './index.js'\n\n/**\n * Sliding-window counter. Weights the previous window's count by how far the\n * current window has elapsed and adds the current count, smoothing out the\n * boundary bursts that fixed-window allows. Uses two counters per identifier.\n */\nexport const slidingWindow: AlgorithmFn = async (store, ctx, identifier, now) => {\n const { limit, windowMs, prefix } = ctx\n const currentStart = Math.floor(now / windowMs) * windowMs\n const previousStart = currentStart - windowMs\n const fraction = (now - currentStart) / windowMs // 0..1 through the current window\n\n const currentKey = `${prefix}:${identifier}:${currentStart}`\n const previousKey = `${prefix}:${identifier}:${previousStart}`\n\n const previousCount = await store.get(previousKey)\n const currentCount = await store.incr(currentKey)\n // Keep the counter alive long enough to still be read as \"previous\" next window.\n if (currentCount === 1) await store.pexpire(currentKey, windowMs * 2)\n\n const weighted = previousCount * (1 - fraction) + currentCount\n const reset = currentStart + windowMs\n const success = weighted <= limit\n return {\n success,\n limit,\n remaining: Math.max(0, Math.floor(limit - weighted)),\n reset,\n retryAfter: success ? 0 : reset - now,\n }\n}\n","import type { Store } from '../types.js'\n\ninterface Entry {\n count: number\n expiresAt: number\n}\n\n/**\n * In-process counter store. Used as the automatic fallback when no Redis is\n * configured. State is per-instance and not shared across processes, so it is\n * meant for local development and single-instance deployments, not distributed\n * production traffic.\n */\nexport class MemoryStore implements Store {\n private readonly map = new Map<string, Entry>()\n private readonly sweeper: ReturnType<typeof setInterval> | undefined\n\n /** @param sweepMs How often to purge expired keys. Set to 0 to disable the timer (lazy expiry still applies). */\n constructor(sweepMs = 30_000) {\n if (sweepMs > 0 && typeof setInterval === 'function') {\n this.sweeper = setInterval(() => this.sweep(), sweepMs)\n // Never keep the event loop alive for cleanup alone.\n this.sweeper.unref?.()\n }\n }\n\n private live(key: string): Entry | undefined {\n const entry = this.map.get(key)\n if (!entry) return undefined\n if (entry.expiresAt <= Date.now()) {\n this.map.delete(key)\n return undefined\n }\n return entry\n }\n\n async incr(key: string): Promise<number> {\n const entry = this.live(key)\n if (!entry) {\n // Default to no expiry; pexpire is expected to follow on the first hit.\n this.map.set(key, { count: 1, expiresAt: Number.POSITIVE_INFINITY })\n return 1\n }\n entry.count += 1\n return entry.count\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n const entry = this.live(key)\n if (entry) entry.expiresAt = Date.now() + ms\n }\n\n async get(key: string): Promise<number> {\n return this.live(key)?.count ?? 0\n }\n\n private sweep(): void {\n const now = Date.now()\n for (const [key, entry] of this.map) {\n if (entry.expiresAt <= now) this.map.delete(key)\n }\n }\n\n /** Stop the background sweeper. Optional; the timer is unref'd. */\n dispose(): void {\n if (this.sweeper) clearInterval(this.sweeper)\n }\n}\n","import type { RedisLike, Store } from '../types.js'\n\n/**\n * Adapts any Redis client with `incr` / `pexpire` / `get` (both `@upstash/redis`\n * and `ioredis` qualify) to the {@link Store} interface.\n */\nexport class RedisStore implements Store {\n constructor(private readonly client: RedisLike) {}\n\n async incr(key: string): Promise<number> {\n return this.client.incr(key)\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n await this.client.pexpire(key, ms)\n }\n\n async get(key: string): Promise<number> {\n const value = await this.client.get(key)\n if (value == null) return 0\n const n = typeof value === 'number' ? value : Number(value)\n return Number.isFinite(n) ? n : 0\n }\n}\n","import type { Store } from '../types.js'\n\nexport interface UpstashRestOptions {\n url: string\n token: string\n /** Custom fetch implementation. Defaults to the global `fetch`. */\n fetch?: typeof fetch\n}\n\n/**\n * Zero-dependency store backed by the Upstash Redis REST API. Uses `fetch`, so\n * it runs anywhere the runtime provides one (Node 18+, edge, workers) without\n * pulling in a Redis client. This is what env auto-detection constructs.\n */\nexport class UpstashRestStore implements Store {\n private readonly url: string\n private readonly token: string\n private readonly fetchImpl: typeof fetch\n\n constructor(options: UpstashRestOptions) {\n this.url = options.url.replace(/\\/$/, '')\n this.token = options.token\n const f = options.fetch ?? globalThis.fetch\n if (typeof f !== 'function') {\n throw new Error('ratefall: no global fetch available; pass a `fetch` implementation to UpstashRestStore.')\n }\n this.fetchImpl = f\n }\n\n private async command(args: (string | number)[]): Promise<unknown> {\n const res = await this.fetchImpl(this.url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(args.map(String)),\n })\n if (!res.ok) {\n const detail = await res.text().catch(() => '')\n throw new Error(`ratefall: Upstash REST error ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ''}`)\n }\n const body = (await res.json()) as { result?: unknown; error?: string }\n if (body.error) {\n throw new Error(`ratefall: Upstash command failed - ${body.error}`)\n }\n return body.result\n }\n\n async incr(key: string): Promise<number> {\n return Number(await this.command(['INCR', key]))\n }\n\n async pexpire(key: string, ms: number): Promise<void> {\n await this.command(['PEXPIRE', key, ms])\n }\n\n async get(key: string): Promise<number> {\n const result = await this.command(['GET', key])\n if (result == null) return 0\n const n = Number(result)\n return Number.isFinite(n) ? n : 0\n }\n}\n","import type { RateLimitOptions, Store } from './types.js'\nimport { MemoryStore } from './stores/memory.js'\nimport { RedisStore } from './stores/redis.js'\nimport { UpstashRestStore } from './stores/upstash-rest.js'\n\nexport interface ResolvedStore {\n store: Store\n /** Whether the store is shared across processes (Redis) vs per-instance (memory). */\n distributed: boolean\n /** Human-readable source, for the dev warning. */\n source: string\n}\n\nfunction readEnv(key: string): string | undefined {\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env\n return env?.[key]\n}\n\n/**\n * Decide which store to use, in priority order:\n * 1. an explicit `store`\n * 2. an explicit `redis` client\n * 3. `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` in the environment\n * 4. in-memory fallback\n */\nexport function resolveStore(options: RateLimitOptions): ResolvedStore {\n if (options.store) {\n return { store: options.store, distributed: true, source: 'custom store' }\n }\n\n if (options.redis) {\n return { store: new RedisStore(options.redis), distributed: true, source: 'redis client' }\n }\n\n const url = readEnv('UPSTASH_REDIS_REST_URL')\n const token = readEnv('UPSTASH_REDIS_REST_TOKEN')\n if (url && token) {\n return { store: new UpstashRestStore({ url, token }), distributed: true, source: 'Upstash REST (env)' }\n }\n\n return { store: new MemoryStore(), distributed: false, source: 'in-memory' }\n}\n\n/** True when the runtime looks like a production deployment. */\nexport function isProduction(): boolean {\n return readEnv('NODE_ENV') === 'production'\n}\n","const UNITS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n}\n\nconst PATTERN = /^(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d)$/\n\n/**\n * Normalize a window into milliseconds. Accepts a raw millisecond number or a\n * duration string like `'500ms'`, `'10s'`, `'1m'`, `'2h'`, `'1d'`.\n */\nexport function parseWindow(window: string | number): number {\n if (typeof window === 'number') {\n if (!Number.isFinite(window) || window <= 0) {\n throw new RangeError(`ratefall: window must be a positive number of milliseconds, got ${window}`)\n }\n return Math.floor(window)\n }\n\n const match = PATTERN.exec(window.trim())\n if (!match) {\n throw new TypeError(`ratefall: invalid window \"${window}\". Use a number of ms or a string like \"10s\", \"1m\", \"2h\".`)\n }\n\n const value = Number(match[1])\n const unit = UNITS[match[2] as string] as number\n const ms = Math.floor(value * unit)\n if (ms <= 0) {\n throw new RangeError(`ratefall: window must be greater than 0, got \"${window}\".`)\n }\n return ms\n}\n","import type { AlgorithmContext, AlgorithmFn } from './algorithms/index.js'\nimport { fixedWindow } from './algorithms/fixed-window.js'\nimport { slidingWindow } from './algorithms/sliding-window.js'\nimport { isProduction, resolveStore } from './resolve-store.js'\nimport type { RateLimiter, RateLimitOptions } from './types.js'\nimport { parseWindow } from './window.js'\n\nconst ALGORITHMS: Record<string, AlgorithmFn> = {\n 'fixed-window': fixedWindow,\n 'sliding-window': slidingWindow,\n}\n\n/**\n * Create a rate limiter. With no store configured it uses an in-memory counter\n * (great for local dev); set `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`,\n * or pass a `redis` client, and the exact same code becomes distributed.\n *\n * @example\n * const limiter = rateLimit({ limit: 10, window: '10s' })\n * const { success } = await limiter.limit(userId)\n * if (!success) return new Response('slow down', { status: 429 })\n */\nexport function rateLimit(options: RateLimitOptions): RateLimiter {\n const { limit, algorithm = 'fixed-window', prefix = 'ratefall', silent = false } = options\n\n if (!Number.isInteger(limit) || limit <= 0) {\n throw new RangeError(`ratefall: limit must be a positive integer, got ${limit}`)\n }\n\n const run = ALGORITHMS[algorithm]\n if (!run) {\n throw new TypeError(`ratefall: unknown algorithm \"${algorithm}\". Use \"fixed-window\" or \"sliding-window\".`)\n }\n\n const windowMs = parseWindow(options.window)\n const { store, distributed, source } = resolveStore(options)\n\n if (!distributed && !silent && isProduction()) {\n // Not an error: single-instance prod is legitimate. But un-shared limits in a\n // multi-instance deploy are a footgun, so make it loud exactly once.\n console.warn(\n `ratefall: running with an in-memory store in production. Limits are per-instance and NOT shared. ` +\n `Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN or pass a \\`redis\\` client. Pass \\`silent: true\\` to hide this.`,\n )\n }\n\n const ctx: AlgorithmContext = { limit, windowMs, prefix }\n\n return {\n async limit(identifier: string) {\n if (!identifier) {\n throw new TypeError('ratefall: identifier must be a non-empty string.')\n }\n return run(store, ctx, identifier, Date.now())\n },\n }\n}\n\n/** The resolved store source, exposed for debugging/tests. */\nexport { resolveStore } from './resolve-store.js'\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ratefall",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A rate limiter that just works: distributed (Redis) in production, in-memory in development, same code, zero store wiring.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"rate-limit",
|
|
7
|
+
"rate-limiter",
|
|
8
|
+
"ratelimit",
|
|
9
|
+
"throttle",
|
|
10
|
+
"redis",
|
|
11
|
+
"upstash",
|
|
12
|
+
"edge",
|
|
13
|
+
"serverless",
|
|
14
|
+
"sliding-window",
|
|
15
|
+
"fixed-window"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Daniel Spatari <daniel@ghostrev.io> (https://danielspatari.com)",
|
|
19
|
+
"homepage": "https://github.com/thtsdaniel/ratefall#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/thtsdaniel/ratefall.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/thtsdaniel/ratefall/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js",
|
|
35
|
+
"require": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"sideEffects": false,
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"dev": "tsup --watch",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.10.0",
|
|
55
|
+
"tsup": "^8.3.5",
|
|
56
|
+
"typescript": "^5.7.2",
|
|
57
|
+
"vitest": "^2.1.8"
|
|
58
|
+
}
|
|
59
|
+
}
|