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 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
+ [![npm](https://img.shields.io/npm/v/ratefall?style=flat-square&color=fb7185)](https://www.npmjs.com/package/ratefall)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/thtsdaniel/ratefall/ci.yml?branch=main&style=flat-square)](https://github.com/thtsdaniel/ratefall/actions)
5
+ [![license](https://img.shields.io/npm/l/ratefall?style=flat-square&color=fb7185)](./LICENSE)
6
+ [![zero deps](https://img.shields.io/badge/dependencies-0-fb7185?style=flat-square)](./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":";;;AAOO,IAAM,WAAA,GAA2B,OAAO,KAAA,EAAO,GAAA,EAAK,YAAY,GAAA,KAAQ;AAC7E,EAAA,MAAM,EAAE,KAAA,EAAO,QAAA,EAAU,MAAA,EAAO,GAAI,GAAA;AACpC,EAAA,MAAM,WAAA,GAAc,IAAA,CAAK,KAAA,CAAM,GAAA,GAAM,QAAQ,CAAA,GAAI,QAAA;AACjD,EAAA,MAAM,MAAM,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,UAAU,IAAI,WAAW,CAAA,CAAA;AAElD,EAAA,MAAM,KAAA,GAAQ,MAAM,KAAA,CAAM,IAAA,CAAK,GAAG,CAAA;AAClC,EAAA,IAAI,UAAU,CAAA,EAAG,MAAM,KAAA,CAAM,OAAA,CAAQ,KAAK,QAAQ,CAAA;AAElD,EAAA,MAAM,QAAQ,WAAA,GAAc,QAAA;AAC5B,EAAA,MAAM,UAAU,KAAA,IAAS,KAAA;AACzB,EAAA,OAAO;AAAA,IACL,OAAA;AAAA,IACA,KAAA;AAAA,IACA,SAAA,EAAW,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,QAAQ,KAAK,CAAA;AAAA,IACpC,KAAA;AAAA,IACA,UAAA,EAAY,OAAA,GAAU,CAAA,GAAI,KAAA,GAAQ;AAAA,GACpC;AACF,CAAA;;;ACjBO,IAAM,aAAA,GAA6B,OAAO,KAAA,EAAO,GAAA,EAAK,YAAY,GAAA,KAAQ;AAC/E,EAAA,MAAM,EAAE,KAAA,EAAO,QAAA,EAAU,MAAA,EAAO,GAAI,GAAA;AACpC,EAAA,MAAM,YAAA,GAAe,IAAA,CAAK,KAAA,CAAM,GAAA,GAAM,QAAQ,CAAA,GAAI,QAAA;AAClD,EAAA,MAAM,gBAAgB,YAAA,GAAe,QAAA;AACrC,EAAA,MAAM,QAAA,GAAA,CAAY,MAAM,YAAA,IAAgB,QAAA;AAExC,EAAA,MAAM,aAAa,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,UAAU,IAAI,YAAY,CAAA,CAAA;AAC1D,EAAA,MAAM,cAAc,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,UAAU,IAAI,aAAa,CAAA,CAAA;AAE5D,EAAA,MAAM,aAAA,GAAgB,MAAM,KAAA,CAAM,GAAA,CAAI,WAAW,CAAA;AACjD,EAAA,MAAM,YAAA,GAAe,MAAM,KAAA,CAAM,IAAA,CAAK,UAAU,CAAA;AAEhD,EAAA,IAAI,iBAAiB,CAAA,EAAG,MAAM,MAAM,OAAA,CAAQ,UAAA,EAAY,WAAW,CAAC,CAAA;AAEpE,EAAA,MAAM,QAAA,GAAW,aAAA,IAAiB,CAAA,GAAI,QAAA,CAAA,GAAY,YAAA;AAClD,EAAA,MAAM,QAAQ,YAAA,GAAe,QAAA;AAC7B,EAAA,MAAM,UAAU,QAAA,IAAY,KAAA;AAC5B,EAAA,OAAO;AAAA,IACL,OAAA;AAAA,IACA,KAAA;AAAA,IACA,SAAA,EAAW,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,KAAA,CAAM,KAAA,GAAQ,QAAQ,CAAC,CAAA;AAAA,IACnD,KAAA;AAAA,IACA,UAAA,EAAY,OAAA,GAAU,CAAA,GAAI,KAAA,GAAQ;AAAA,GACpC;AACF,CAAA;;;AClBO,IAAM,cAAN,MAAmC;AAAA,EACvB,GAAA,uBAAU,GAAA,EAAmB;AAAA,EAC7B,OAAA;AAAA;AAAA,EAGjB,WAAA,CAAY,UAAU,GAAA,EAAQ;AAC5B,IAAA,IAAI,OAAA,GAAU,CAAA,IAAK,OAAO,WAAA,KAAgB,UAAA,EAAY;AACpD,MAAA,IAAA,CAAK,UAAU,WAAA,CAAY,MAAM,IAAA,CAAK,KAAA,IAAS,OAAO,CAAA;AAEtD,MAAA,IAAA,CAAK,QAAQ,KAAA,IAAQ;AAAA,IACvB;AAAA,EACF;AAAA,EAEQ,KAAK,GAAA,EAAgC;AAC3C,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA;AAC9B,IAAA,IAAI,CAAC,OAAO,OAAO,MAAA;AACnB,IAAA,IAAI,KAAA,CAAM,SAAA,IAAa,IAAA,CAAK,GAAA,EAAI,EAAG;AACjC,MAAA,IAAA,CAAK,GAAA,CAAI,OAAO,GAAG,CAAA;AACnB,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO,KAAA;AAAA,EACT;AAAA,EAEA,MAAM,KAAK,GAAA,EAA8B;AACvC,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,IAAA,CAAK,GAAG,CAAA;AAC3B,IAAA,IAAI,CAAC,KAAA,EAAO;AAEV,MAAA,IAAA,CAAK,GAAA,CAAI,IAAI,GAAA,EAAK,EAAE,OAAO,CAAA,EAAG,SAAA,EAAW,MAAA,CAAO,iBAAA,EAAmB,CAAA;AACnE,MAAA,OAAO,CAAA;AAAA,IACT;AACA,IAAA,KAAA,CAAM,KAAA,IAAS,CAAA;AACf,IAAA,OAAO,KAAA,CAAM,KAAA;AAAA,EACf;AAAA,EAEA,MAAM,OAAA,CAAQ,GAAA,EAAa,EAAA,EAA2B;AACpD,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,IAAA,CAAK,GAAG,CAAA;AAC3B,IAAA,IAAI,KAAA,EAAO,KAAA,CAAM,SAAA,GAAY,IAAA,CAAK,KAAI,GAAI,EAAA;AAAA,EAC5C;AAAA,EAEA,MAAM,IAAI,GAAA,EAA8B;AACtC,IAAA,OAAO,IAAA,CAAK,IAAA,CAAK,GAAG,CAAA,EAAG,KAAA,IAAS,CAAA;AAAA,EAClC;AAAA,EAEQ,KAAA,GAAc;AACpB,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,CAAA,IAAK,KAAK,GAAA,EAAK;AACnC,MAAA,IAAI,MAAM,SAAA,IAAa,GAAA,EAAK,IAAA,CAAK,GAAA,CAAI,OAAO,GAAG,CAAA;AAAA,IACjD;AAAA,EACF;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,IAAI,IAAA,CAAK,OAAA,EAAS,aAAA,CAAc,IAAA,CAAK,OAAO,CAAA;AAAA,EAC9C;AACF;;;AC7DO,IAAM,aAAN,MAAkC;AAAA,EACvC,YAA6B,MAAA,EAAmB;AAAnB,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AAAA,EAAoB;AAAA,EAApB,MAAA;AAAA,EAE7B,MAAM,KAAK,GAAA,EAA8B;AACvC,IAAA,OAAO,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,GAAG,CAAA;AAAA,EAC7B;AAAA,EAEA,MAAM,OAAA,CAAQ,GAAA,EAAa,EAAA,EAA2B;AACpD,IAAA,MAAM,IAAA,CAAK,MAAA,CAAO,OAAA,CAAQ,GAAA,EAAK,EAAE,CAAA;AAAA,EACnC;AAAA,EAEA,MAAM,IAAI,GAAA,EAA8B;AACtC,IAAA,MAAM,KAAA,GAAQ,MAAM,IAAA,CAAK,MAAA,CAAO,IAAI,GAAG,CAAA;AACvC,IAAA,IAAI,KAAA,IAAS,MAAM,OAAO,CAAA;AAC1B,IAAA,MAAM,IAAI,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,GAAQ,OAAO,KAAK,CAAA;AAC1D,IAAA,OAAO,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,GAAI,CAAA,GAAI,CAAA;AAAA,EAClC;AACF;;;ACTO,IAAM,mBAAN,MAAwC;AAAA,EAC5B,GAAA;AAAA,EACA,KAAA;AAAA,EACA,SAAA;AAAA,EAEjB,YAAY,OAAA,EAA6B;AACvC,IAAA,IAAA,CAAK,GAAA,GAAM,OAAA,CAAQ,GAAA,CAAI,OAAA,CAAQ,OAAO,EAAE,CAAA;AACxC,IAAA,IAAA,CAAK,QAAQ,OAAA,CAAQ,KAAA;AACrB,IAAA,MAAM,CAAA,GAAI,OAAA,CAAQ,KAAA,IAAS,UAAA,CAAW,KAAA;AACtC,IAAA,IAAI,OAAO,MAAM,UAAA,EAAY;AAC3B,MAAA,MAAM,IAAI,MAAM,yFAAyF,CAAA;AAAA,IAC3G;AACA,IAAA,IAAA,CAAK,SAAA,GAAY,CAAA;AAAA,EACnB;AAAA,EAEA,MAAc,QAAQ,IAAA,EAA6C;AACjE,IAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,SAAA,CAAU,KAAK,GAAA,EAAK;AAAA,MACzC,MAAA,EAAQ,MAAA;AAAA,MACR,OAAA,EAAS;AAAA,QACP,aAAA,EAAe,CAAA,OAAA,EAAU,IAAA,CAAK,KAAK,CAAA,CAAA;AAAA,QACnC,cAAA,EAAgB;AAAA,OAClB;AAAA,MACA,MAAM,IAAA,CAAK,SAAA,CAAU,IAAA,CAAK,GAAA,CAAI,MAAM,CAAC;AAAA,KACtC,CAAA;AACD,IAAA,IAAI,CAAC,IAAI,EAAA,EAAI;AACX,MAAA,MAAM,SAAS,MAAM,GAAA,CAAI,MAAK,CAAE,KAAA,CAAM,MAAM,EAAE,CAAA;AAC9C,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,6BAAA,EAAgC,GAAA,CAAI,MAAM,CAAA,CAAA,EAAI,GAAA,CAAI,UAAU,CAAA,EAAG,MAAA,GAAS,CAAA,GAAA,EAAM,MAAM,CAAA,CAAA,GAAK,EAAE,CAAA,CAAE,CAAA;AAAA,IAC/G;AACA,IAAA,MAAM,IAAA,GAAQ,MAAM,GAAA,CAAI,IAAA,EAAK;AAC7B,IAAA,IAAI,KAAK,KAAA,EAAO;AACd,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mCAAA,EAAsC,IAAA,CAAK,KAAK,CAAA,CAAE,CAAA;AAAA,IACpE;AACA,IAAA,OAAO,IAAA,CAAK,MAAA;AAAA,EACd;AAAA,EAEA,MAAM,KAAK,GAAA,EAA8B;AACvC,IAAA,OAAO,MAAA,CAAO,MAAM,IAAA,CAAK,OAAA,CAAQ,CAAC,MAAA,EAAQ,GAAG,CAAC,CAAC,CAAA;AAAA,EACjD;AAAA,EAEA,MAAM,OAAA,CAAQ,GAAA,EAAa,EAAA,EAA2B;AACpD,IAAA,MAAM,KAAK,OAAA,CAAQ,CAAC,SAAA,EAAW,GAAA,EAAK,EAAE,CAAC,CAAA;AAAA,EACzC;AAAA,EAEA,MAAM,IAAI,GAAA,EAA8B;AACtC,IAAA,MAAM,SAAS,MAAM,IAAA,CAAK,QAAQ,CAAC,KAAA,EAAO,GAAG,CAAC,CAAA;AAC9C,IAAA,IAAI,MAAA,IAAU,MAAM,OAAO,CAAA;AAC3B,IAAA,MAAM,CAAA,GAAI,OAAO,MAAM,CAAA;AACvB,IAAA,OAAO,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,GAAI,CAAA,GAAI,CAAA;AAAA,EAClC;AACF;;;AClDA,SAAS,QAAQ,GAAA,EAAiC;AAChD,EAAA,MAAM,GAAA,GAAO,WAA0E,OAAA,EAAS,GAAA;AAChG,EAAA,OAAO,MAAM,GAAG,CAAA;AAClB;AASO,SAAS,aAAa,OAAA,EAA0C;AACrE,EAAA,IAAI,QAAQ,KAAA,EAAO;AACjB,IAAA,OAAO,EAAE,KAAA,EAAO,OAAA,CAAQ,OAAO,WAAA,EAAa,IAAA,EAAM,QAAQ,cAAA,EAAe;AAAA,EAC3E;AAEA,EAAA,IAAI,QAAQ,KAAA,EAAO;AACjB,IAAA,OAAO,EAAE,KAAA,EAAO,IAAI,UAAA,CAAW,OAAA,CAAQ,KAAK,CAAA,EAAG,WAAA,EAAa,IAAA,EAAM,MAAA,EAAQ,cAAA,EAAe;AAAA,EAC3F;AAEA,EAAA,MAAM,GAAA,GAAM,QAAQ,wBAAwB,CAAA;AAC5C,EAAA,MAAM,KAAA,GAAQ,QAAQ,0BAA0B,CAAA;AAChD,EAAA,IAAI,OAAO,KAAA,EAAO;AAChB,IAAA,OAAO,EAAE,KAAA,EAAO,IAAI,gBAAA,CAAiB,EAAE,GAAA,EAAK,KAAA,EAAO,CAAA,EAAG,WAAA,EAAa,IAAA,EAAM,MAAA,EAAQ,oBAAA,EAAqB;AAAA,EACxG;AAEA,EAAA,OAAO,EAAE,OAAO,IAAI,WAAA,IAAe,WAAA,EAAa,KAAA,EAAO,QAAQ,WAAA,EAAY;AAC7E;AAGO,SAAS,YAAA,GAAwB;AACtC,EAAA,OAAO,OAAA,CAAQ,UAAU,CAAA,KAAM,YAAA;AACjC;;;AC9CA,IAAM,KAAA,GAAgC;AAAA,EACpC,EAAA,EAAI,CAAA;AAAA,EACJ,CAAA,EAAG,GAAA;AAAA,EACH,CAAA,EAAG,GAAA;AAAA,EACH,CAAA,EAAG,IAAA;AAAA,EACH,CAAA,EAAG;AACL,CAAA;AAEA,IAAM,OAAA,GAAU,kCAAA;AAMT,SAAS,YAAY,MAAA,EAAiC;AAC3D,EAAA,IAAI,OAAO,WAAW,QAAA,EAAU;AAC9B,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,MAAM,CAAA,IAAK,UAAU,CAAA,EAAG;AAC3C,MAAA,MAAM,IAAI,UAAA,CAAW,CAAA,gEAAA,EAAmE,MAAM,CAAA,CAAE,CAAA;AAAA,IAClG;AACA,IAAA,OAAO,IAAA,CAAK,MAAM,MAAM,CAAA;AAAA,EAC1B;AAEA,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,IAAA,CAAK,MAAA,CAAO,MAAM,CAAA;AACxC,EAAA,IAAI,CAAC,KAAA,EAAO;AACV,IAAA,MAAM,IAAI,SAAA,CAAU,CAAA,0BAAA,EAA6B,MAAM,CAAA,yDAAA,CAA2D,CAAA;AAAA,EACpH;AAEA,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,KAAA,CAAM,CAAC,CAAC,CAAA;AAC7B,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,KAAA,CAAM,CAAC,CAAW,CAAA;AACrC,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,IAAI,CAAA;AAClC,EAAA,IAAI,MAAM,CAAA,EAAG;AACX,IAAA,MAAM,IAAI,UAAA,CAAW,CAAA,8CAAA,EAAiD,MAAM,CAAA,EAAA,CAAI,CAAA;AAAA,EAClF;AACA,EAAA,OAAO,EAAA;AACT;;;AC3BA,IAAM,UAAA,GAA0C;AAAA,EAC9C,cAAA,EAAgB,WAAA;AAAA,EAChB,gBAAA,EAAkB;AACpB,CAAA;AAYO,SAAS,UAAU,OAAA,EAAwC;AAChE,EAAA,MAAM,EAAE,OAAO,SAAA,GAAY,cAAA,EAAgB,SAAS,UAAA,EAAY,MAAA,GAAS,OAAM,GAAI,OAAA;AAEnF,EAAA,IAAI,CAAC,MAAA,CAAO,SAAA,CAAU,KAAK,CAAA,IAAK,SAAS,CAAA,EAAG;AAC1C,IAAA,MAAM,IAAI,UAAA,CAAW,CAAA,gDAAA,EAAmD,KAAK,CAAA,CAAE,CAAA;AAAA,EACjF;AAEA,EAAA,MAAM,GAAA,GAAM,WAAW,SAAS,CAAA;AAChC,EAAA,IAAI,CAAC,GAAA,EAAK;AACR,IAAA,MAAM,IAAI,SAAA,CAAU,CAAA,6BAAA,EAAgC,SAAS,CAAA,0CAAA,CAA4C,CAAA;AAAA,EAC3G;AAEA,EAAA,MAAM,QAAA,GAAW,WAAA,CAAY,OAAA,CAAQ,MAAM,CAAA;AAC3C,EAAA,MAAM,EAAE,KAAA,EAAO,WAAA,EAAa,MAAA,EAAO,GAAI,aAAa,OAAO,CAAA;AAE3D,EAAA,IAAI,CAAC,WAAA,IAAe,CAAC,MAAA,IAAU,cAAa,EAAG;AAG7C,IAAA,OAAA,CAAQ,IAAA;AAAA,MACN,CAAA,sNAAA;AAAA,KAEF;AAAA,EACF;AAEA,EAAA,MAAM,GAAA,GAAwB,EAAE,KAAA,EAAO,QAAA,EAAU,MAAA,EAAO;AAExD,EAAA,OAAO;AAAA,IACL,MAAM,MAAM,UAAA,EAAoB;AAC9B,MAAA,IAAI,CAAC,UAAA,EAAY;AACf,QAAA,MAAM,IAAI,UAAU,kDAAkD,CAAA;AAAA,MACxE;AACA,MAAA,OAAO,IAAI,KAAA,EAAO,GAAA,EAAK,UAAA,EAAY,IAAA,CAAK,KAAK,CAAA;AAAA,IAC/C;AAAA,GACF;AACF","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"]}
@@ -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 };
@@ -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
+ }