io-ratelimiter 1.0.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) 2024 Devhuset
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,292 @@
1
+ # io-ratelimiter
2
+
3
+ [![npm version](https://badge.fury.io/js/io-ratelimiter.svg)](https://badge.fury.io/js/io-ratelimiter)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ A flexible Redis-based rate limiting library supporting both fixed and sliding window algorithms. Works with **any Redis client** (ioredis, iovalkey, node-redis, Bun's native client, etc.).
7
+
8
+ ## Features
9
+
10
+ - **Fixed window** rate limiting
11
+ - **Sliding window** rate limiting with weighted scoring
12
+ - Redis/Valkey-backed for distributed systems
13
+ - Full TypeScript support
14
+ - High performance Lua script execution (EVALSHA)
15
+ - Protection against race conditions
16
+ - **Client-agnostic** - bring your own Redis client
17
+ - Zero runtime dependencies
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ bun add io-ratelimiter
23
+ # or
24
+ npm install io-ratelimiter
25
+ # or
26
+ yarn add io-ratelimiter
27
+ # or
28
+ pnpm add io-ratelimiter
29
+ ```
30
+
31
+ You'll also need a Redis client:
32
+
33
+ ```bash
34
+ # Choose one:
35
+ npm install ioredis
36
+ npm install iovalkey
37
+ npm install redis # node-redis
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```typescript
43
+ import { Ratelimit } from 'io-ratelimiter';
44
+ import Redis from 'iovalkey'; // or 'ioredis' or 'redis'
45
+
46
+ // Create any Redis client
47
+ const client = new Redis('redis://localhost:6379');
48
+
49
+ // Create rate limiter (10 requests per 60 seconds)
50
+ const limiter = new Ratelimit(
51
+ client,
52
+ Ratelimit.slidingWindow({
53
+ limit: 10,
54
+ window: 60, // seconds
55
+ prefix: 'my-api', // optional
56
+ }),
57
+ );
58
+
59
+ // Check rate limit
60
+ const result = await limiter.limit('user-123');
61
+ if (result.success) {
62
+ // Process request
63
+ console.log(`${result.remaining} requests remaining`);
64
+ } else {
65
+ // Rate limit exceeded
66
+ console.log(`Try again in ${result.retry_after}ms`);
67
+ }
68
+ ```
69
+
70
+ ## Rate Limiting Algorithms
71
+
72
+ ### Fixed Window
73
+
74
+ Divides time into fixed intervals (e.g., 60-second windows) and tracks requests within each window. Simple and fast.
75
+
76
+ ```typescript
77
+ const limiter = new Ratelimit(
78
+ client,
79
+ Ratelimit.fixedWindow({
80
+ limit: 100,
81
+ window: 60,
82
+ prefix: 'api',
83
+ }),
84
+ );
85
+ ```
86
+
87
+ **Best for**: High-throughput scenarios where slight bursts are acceptable
88
+
89
+ ### Sliding Window
90
+
91
+ Provides smoother rate limiting by considering both current and previous windows with weighted rates. More accurate but requires Lua script support.
92
+
93
+ ```typescript
94
+ const limiter = new Ratelimit(
95
+ client,
96
+ Ratelimit.slidingWindow({
97
+ limit: 100,
98
+ window: 60,
99
+ prefix: 'api',
100
+ }),
101
+ );
102
+ ```
103
+
104
+ **Best for**: Preventing burst traffic, fair rate limiting
105
+
106
+ **Note**: Requires `script()` and `evalsha()` support. Works with ioredis, iovalkey, and node-redis. Bun's native Redis client currently only supports fixed window.
107
+
108
+ ## Client Compatibility
109
+
110
+ | Client | Fixed Window | Sliding Window |
111
+ | ------------------------------------------------- | ------------ | -------------- |
112
+ | [ioredis](https://github.com/redis/ioredis) | ✅ | ✅ |
113
+ | [iovalkey](https://github.com/valkey-io/iovalkey) | ✅ | ✅ |
114
+ | [node-redis](https://github.com/redis/node-redis) | ✅ | ✅ |
115
+ | [Bun Redis](https://bun.sh/docs/runtime/redis) | ✅ | ❌ |
116
+
117
+ ## API Reference
118
+
119
+ ### `Ratelimit` Class
120
+
121
+ ```typescript
122
+ import { Ratelimit, type RedisClient, type RatelimitResponse } from 'io-ratelimiter';
123
+
124
+ const limiter = new Ratelimit(client: RedisClient, options: RatelimitOptions);
125
+ ```
126
+
127
+ ### Configuration Options
128
+
129
+ ```typescript
130
+ interface RatelimitOptionsWithoutType {
131
+ /** Maximum requests per window */
132
+ limit: number;
133
+ /** Window duration in seconds */
134
+ window: number;
135
+ /** Optional Redis key prefix */
136
+ prefix?: string;
137
+ }
138
+
139
+ // Factory methods
140
+ Ratelimit.fixedWindow(options: RatelimitOptionsWithoutType)
141
+ Ratelimit.slidingWindow(options: RatelimitOptionsWithoutType)
142
+ ```
143
+
144
+ ### Response Type
145
+
146
+ ```typescript
147
+ interface RatelimitResponse {
148
+ /** Whether the request is allowed */
149
+ success: boolean;
150
+ /** Maximum number of requests allowed in the window */
151
+ limit: number;
152
+ /** Number of remaining requests in current window */
153
+ remaining: number;
154
+ /** Time in milliseconds until the next request will be allowed (0 if under limit) */
155
+ retry_after: number;
156
+ /** Time in milliseconds when the current window expires completely */
157
+ reset: number;
158
+ }
159
+ ```
160
+
161
+ ### `limit()` Method
162
+
163
+ ```typescript
164
+ await limiter.limit(identifier: string): Promise<RatelimitResponse>
165
+ ```
166
+
167
+ The `identifier` is typically a user ID, IP address, or API key.
168
+
169
+ ## Framework Integration Examples
170
+
171
+ ### Next.js App Router
172
+
173
+ ```typescript
174
+ import { Ratelimit } from 'io-ratelimiter';
175
+ import { NextResponse } from 'next/server';
176
+ import { headers } from 'next/headers';
177
+ import Redis from 'iovalkey';
178
+
179
+ const client = new Redis(process.env.REDIS_URL);
180
+ const ratelimit = new Ratelimit(
181
+ client,
182
+ Ratelimit.slidingWindow({ limit: 10, window: 60 }),
183
+ );
184
+
185
+ export async function GET() {
186
+ const headersList = await headers();
187
+ const ip = headersList.get('x-forwarded-for') || '127.0.0.1';
188
+
189
+ const { success, remaining, reset, retry_after } =
190
+ await ratelimit.limit(ip);
191
+
192
+ if (!success) {
193
+ return NextResponse.json(
194
+ { error: 'Too many requests' },
195
+ {
196
+ status: 429,
197
+ headers: {
198
+ 'X-RateLimit-Limit': '10',
199
+ 'X-RateLimit-Remaining': remaining.toString(),
200
+ 'X-RateLimit-Reset': reset.toString(),
201
+ 'Retry-After': Math.ceil(retry_after / 1000).toString(),
202
+ },
203
+ },
204
+ );
205
+ }
206
+
207
+ return NextResponse.json({ message: 'Success' });
208
+ }
209
+ ```
210
+
211
+ ### Express Middleware
212
+
213
+ ```typescript
214
+ import { Ratelimit } from 'io-ratelimiter';
215
+ import express from 'express';
216
+ import Redis from 'iovalkey';
217
+
218
+ const app = express();
219
+ const client = new Redis(process.env.REDIS_URL);
220
+ const ratelimit = new Ratelimit(
221
+ client,
222
+ Ratelimit.slidingWindow({ limit: 100, window: 60 }),
223
+ );
224
+
225
+ app.use(async (req, res, next) => {
226
+ const { success, remaining, reset, retry_after } = await ratelimit.limit(
227
+ req.ip,
228
+ );
229
+
230
+ res.setHeader('X-RateLimit-Limit', '100');
231
+ res.setHeader('X-RateLimit-Remaining', remaining.toString());
232
+ res.setHeader('X-RateLimit-Reset', reset.toString());
233
+
234
+ if (!success) {
235
+ res.setHeader('Retry-After', Math.ceil(retry_after / 1000).toString());
236
+ return res.status(429).json({ error: 'Too many requests' });
237
+ }
238
+
239
+ next();
240
+ });
241
+ ```
242
+
243
+ ### Hono
244
+
245
+ ```typescript
246
+ import { Ratelimit } from 'io-ratelimiter';
247
+ import { Hono } from 'hono';
248
+ import Redis from 'iovalkey';
249
+
250
+ const app = new Hono();
251
+ const client = new Redis(process.env.REDIS_URL);
252
+ const ratelimit = new Ratelimit(
253
+ client,
254
+ Ratelimit.fixedWindow({ limit: 50, window: 60 }),
255
+ );
256
+
257
+ app.use('*', async (c, next) => {
258
+ const ip = c.req.header('x-forwarded-for') || 'unknown';
259
+ const { success, remaining, reset, retry_after } =
260
+ await ratelimit.limit(ip);
261
+
262
+ c.header('X-RateLimit-Limit', '50');
263
+ c.header('X-RateLimit-Remaining', remaining.toString());
264
+
265
+ if (!success) {
266
+ return c.json({ error: 'Too many requests' }, 429);
267
+ }
268
+
269
+ await next();
270
+ });
271
+ ```
272
+
273
+ ## Performance
274
+
275
+ The sliding window algorithm uses `SCRIPT LOAD` + `EVALSHA` for optimal performance:
276
+
277
+ - Script is loaded once on first use
278
+ - Subsequent requests send only a 40-byte SHA hash instead of the full ~800-byte Lua script
279
+ - Atomic operations prevent race conditions
280
+ - Automatic retry on script loss (e.g., Redis restart)
281
+
282
+ ## Contributing
283
+
284
+ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
285
+
286
+ ## License
287
+
288
+ [MIT](https://choosealicense.com/licenses/mit/)
289
+
290
+ ## Credits
291
+
292
+ Built by [Kasper](https://twitter.com/kasperaamodt)
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Thrown when rate limiter configuration is invalid
3
+ * @example
4
+ * ```ts
5
+ * throw new ConfigurationError('Limit must be greater than 0')
6
+ * ```
7
+ */
8
+ declare class ConfigurationError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /**
12
+ * Thrown when rate limit operations fail. Includes the original error if available
13
+ * @example
14
+ * ```ts
15
+ * throw new RatelimitError('Failed to check rate limit', originalError)
16
+ * ```
17
+ */
18
+ declare class RatelimitError extends Error {
19
+ originalError?: Error | undefined;
20
+ constructor(message: string, originalError?: Error | undefined);
21
+ }
22
+
23
+ /**
24
+ * Minimal Redis client interface required for rate limiting.
25
+ * Compatible with ioredis, iovalkey, node-redis, Bun's native Redis client, and other Redis clients.
26
+ *
27
+ * Note: For sliding window algorithm, `script` and `evalsha` are required.
28
+ * Bun's native Redis client currently doesn't support these, so use ioredis/iovalkey for sliding window.
29
+ */
30
+ interface RedisClient {
31
+ incr(key: string): Promise<number>;
32
+ expire(key: string, seconds: number): Promise<number>;
33
+ ttl(key: string): Promise<number>;
34
+ script?(...args: unknown[]): Promise<unknown>;
35
+ evalsha?(sha: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
36
+ flushdb?(): Promise<unknown>;
37
+ close?(): void;
38
+ quit?(): Promise<unknown>;
39
+ }
40
+
41
+ /**
42
+ * Response from a rate limit check
43
+ */
44
+ interface RatelimitResponse {
45
+ /** Whether the request should be allowed */
46
+ success: boolean;
47
+ /** Maximum number of requests allowed in the window */
48
+ limit: number;
49
+ /** Number of remaining requests in current window */
50
+ remaining: number;
51
+ /** Time in milliseconds until the next request will be allowed. 0 if under limit */
52
+ retry_after: number;
53
+ /** Time in milliseconds when the current window expires completely */
54
+ reset: number;
55
+ }
56
+ /**
57
+ * Base configuration options for both fixed and sliding window rate limiters
58
+ */
59
+ interface RatelimitOptionsWithoutType {
60
+ /** Maximum number of requests allowed within the window */
61
+ limit: number;
62
+ /** Time window in seconds */
63
+ window: number;
64
+ /** Optional prefix for Valkey keys to prevent collisions */
65
+ prefix?: string;
66
+ }
67
+ /**
68
+ * Complete rate limiter configuration including window type
69
+ */
70
+ interface RatelimitOptions extends RatelimitOptionsWithoutType {
71
+ /** Type of rate limiting window to use */
72
+ type: 'fixed' | 'sliding';
73
+ }
74
+
75
+ /**
76
+ * Redis-based rate limiter supporting both fixed and sliding window algorithms.
77
+ * Fixed window divides time into discrete chunks while sliding window
78
+ * provides smoother rate limiting using weighted scoring.
79
+ *
80
+ * Accepts any Redis-compatible client (ioredis, iovalkey, node-redis, etc.).
81
+ */
82
+ declare class Ratelimit {
83
+ private readonly options;
84
+ private readonly time_provider;
85
+ private readonly client;
86
+ private scriptSha?;
87
+ constructor(client: RedisClient, options: RatelimitOptions, time_provider?: () => number);
88
+ static fixedWindow(params: RatelimitOptionsWithoutType): RatelimitOptions;
89
+ static slidingWindow(params: RatelimitOptionsWithoutType): RatelimitOptions;
90
+ limit(identifier: string): Promise<RatelimitResponse>;
91
+ private validateOptions;
92
+ private getKey;
93
+ private fixedWindowLimit;
94
+ private slidingWindowLimit;
95
+ private evalSlidingWindow;
96
+ }
97
+
98
+ export { ConfigurationError, Ratelimit, RatelimitError, type RatelimitOptions, type RatelimitOptionsWithoutType, type RatelimitResponse, type RedisClient };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Thrown when rate limiter configuration is invalid
3
+ * @example
4
+ * ```ts
5
+ * throw new ConfigurationError('Limit must be greater than 0')
6
+ * ```
7
+ */
8
+ declare class ConfigurationError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /**
12
+ * Thrown when rate limit operations fail. Includes the original error if available
13
+ * @example
14
+ * ```ts
15
+ * throw new RatelimitError('Failed to check rate limit', originalError)
16
+ * ```
17
+ */
18
+ declare class RatelimitError extends Error {
19
+ originalError?: Error | undefined;
20
+ constructor(message: string, originalError?: Error | undefined);
21
+ }
22
+
23
+ /**
24
+ * Minimal Redis client interface required for rate limiting.
25
+ * Compatible with ioredis, iovalkey, node-redis, Bun's native Redis client, and other Redis clients.
26
+ *
27
+ * Note: For sliding window algorithm, `script` and `evalsha` are required.
28
+ * Bun's native Redis client currently doesn't support these, so use ioredis/iovalkey for sliding window.
29
+ */
30
+ interface RedisClient {
31
+ incr(key: string): Promise<number>;
32
+ expire(key: string, seconds: number): Promise<number>;
33
+ ttl(key: string): Promise<number>;
34
+ script?(...args: unknown[]): Promise<unknown>;
35
+ evalsha?(sha: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
36
+ flushdb?(): Promise<unknown>;
37
+ close?(): void;
38
+ quit?(): Promise<unknown>;
39
+ }
40
+
41
+ /**
42
+ * Response from a rate limit check
43
+ */
44
+ interface RatelimitResponse {
45
+ /** Whether the request should be allowed */
46
+ success: boolean;
47
+ /** Maximum number of requests allowed in the window */
48
+ limit: number;
49
+ /** Number of remaining requests in current window */
50
+ remaining: number;
51
+ /** Time in milliseconds until the next request will be allowed. 0 if under limit */
52
+ retry_after: number;
53
+ /** Time in milliseconds when the current window expires completely */
54
+ reset: number;
55
+ }
56
+ /**
57
+ * Base configuration options for both fixed and sliding window rate limiters
58
+ */
59
+ interface RatelimitOptionsWithoutType {
60
+ /** Maximum number of requests allowed within the window */
61
+ limit: number;
62
+ /** Time window in seconds */
63
+ window: number;
64
+ /** Optional prefix for Valkey keys to prevent collisions */
65
+ prefix?: string;
66
+ }
67
+ /**
68
+ * Complete rate limiter configuration including window type
69
+ */
70
+ interface RatelimitOptions extends RatelimitOptionsWithoutType {
71
+ /** Type of rate limiting window to use */
72
+ type: 'fixed' | 'sliding';
73
+ }
74
+
75
+ /**
76
+ * Redis-based rate limiter supporting both fixed and sliding window algorithms.
77
+ * Fixed window divides time into discrete chunks while sliding window
78
+ * provides smoother rate limiting using weighted scoring.
79
+ *
80
+ * Accepts any Redis-compatible client (ioredis, iovalkey, node-redis, etc.).
81
+ */
82
+ declare class Ratelimit {
83
+ private readonly options;
84
+ private readonly time_provider;
85
+ private readonly client;
86
+ private scriptSha?;
87
+ constructor(client: RedisClient, options: RatelimitOptions, time_provider?: () => number);
88
+ static fixedWindow(params: RatelimitOptionsWithoutType): RatelimitOptions;
89
+ static slidingWindow(params: RatelimitOptionsWithoutType): RatelimitOptions;
90
+ limit(identifier: string): Promise<RatelimitResponse>;
91
+ private validateOptions;
92
+ private getKey;
93
+ private fixedWindowLimit;
94
+ private slidingWindowLimit;
95
+ private evalSlidingWindow;
96
+ }
97
+
98
+ export { ConfigurationError, Ratelimit, RatelimitError, type RatelimitOptions, type RatelimitOptionsWithoutType, type RatelimitResponse, type RedisClient };
package/dist/index.js ADDED
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var ConfigurationError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ConfigurationError";
8
+ }
9
+ };
10
+ var RatelimitError = class extends Error {
11
+ constructor(message, originalError) {
12
+ super(message);
13
+ this.originalError = originalError;
14
+ this.name = "RatelimitError";
15
+ this.stack = new Error().stack;
16
+ this.message = message;
17
+ this.originalError = originalError;
18
+ }
19
+ };
20
+
21
+ // src/client.ts
22
+ var SLIDING_WINDOW_SCRIPT = `
23
+ local current_key = KEYS[1]
24
+ local previous_key = KEYS[2]
25
+ local tokens = tonumber(ARGV[1])
26
+ local now = tonumber(ARGV[2])
27
+ local window = tonumber(ARGV[3])
28
+ local increment_by = tonumber(ARGV[4])
29
+
30
+ local current_count = tonumber(redis.call("GET", current_key) or "0")
31
+ local previous_count = tonumber(redis.call("GET", previous_key) or "0")
32
+
33
+ local time_in_current = now % window
34
+ local time_remaining_previous = window - time_in_current
35
+ local weighted_previous = (previous_count * time_remaining_previous) / window
36
+ local cumulative_count = math.floor(weighted_previous) + current_count + increment_by
37
+
38
+ if cumulative_count > tokens then
39
+ local needed = cumulative_count - tokens + increment_by
40
+ local retry_after = window - time_in_current
41
+
42
+ if previous_count > 0 then
43
+ local time_needed = (needed * window) / previous_count
44
+ retry_after = math.ceil(time_needed)
45
+
46
+ if retry_after > time_remaining_previous then
47
+ retry_after = time_remaining_previous
48
+ end
49
+ end
50
+
51
+ return { -1, retry_after }
52
+ end
53
+
54
+ current_count = current_count + increment_by
55
+ redis.call("SET", current_key, current_count)
56
+ redis.call("PEXPIRE", current_key, window * 2 + 1000)
57
+
58
+ return { tokens - (math.floor(weighted_previous) + current_count), 0 }
59
+ `;
60
+
61
+ // src/ratelimit.ts
62
+ var Ratelimit = class {
63
+ constructor(client, options, time_provider = Date.now) {
64
+ this.options = options;
65
+ this.time_provider = time_provider;
66
+ this.client = client;
67
+ this.validateOptions(options);
68
+ }
69
+ static fixedWindow(params) {
70
+ return { type: "fixed", ...params };
71
+ }
72
+ static slidingWindow(params) {
73
+ return { type: "sliding", ...params };
74
+ }
75
+ async limit(identifier) {
76
+ try {
77
+ return this.options.type === "fixed" ? await this.fixedWindowLimit(identifier) : await this.slidingWindowLimit(identifier);
78
+ } catch (error) {
79
+ if (error instanceof ConfigurationError) {
80
+ throw error;
81
+ }
82
+ throw new RatelimitError(
83
+ "Failed to check rate limit",
84
+ error instanceof Error ? error : new Error(String(error))
85
+ );
86
+ }
87
+ }
88
+ validateOptions(options) {
89
+ if (options.limit <= 0) {
90
+ throw new ConfigurationError("Limit must be greater than 0");
91
+ }
92
+ if (options.window <= 0) {
93
+ throw new ConfigurationError("Time window must be greater than 0");
94
+ }
95
+ if (options.type !== "fixed" && options.type !== "sliding") {
96
+ throw new ConfigurationError(
97
+ 'Type must be either "fixed" or "sliding"'
98
+ );
99
+ }
100
+ }
101
+ getKey(identifier, suffix) {
102
+ const prefix = this.options.prefix || "ratelimit";
103
+ return `${prefix}:${identifier}:${suffix}`;
104
+ }
105
+ async fixedWindowLimit(identifier) {
106
+ const now = this.time_provider();
107
+ const window_size = this.options.window;
108
+ const current_window = Math.floor(now / (window_size * 1e3));
109
+ const window_key = this.getKey(identifier, current_window.toString());
110
+ const window_end = (current_window + 1) * (window_size * 1e3);
111
+ const count = await this.client.incr(window_key);
112
+ if (count === 1) {
113
+ await this.client.expire(window_key, window_size);
114
+ }
115
+ if (count > this.options.limit) {
116
+ const ttl = await this.client.ttl(window_key);
117
+ return {
118
+ success: false,
119
+ limit: this.options.limit,
120
+ remaining: 0,
121
+ retry_after: Math.max(ttl * 1e3, 0),
122
+ reset: window_end
123
+ };
124
+ }
125
+ return {
126
+ success: true,
127
+ limit: this.options.limit,
128
+ remaining: this.options.limit - count,
129
+ retry_after: 0,
130
+ reset: window_end
131
+ };
132
+ }
133
+ async slidingWindowLimit(identifier) {
134
+ const now = this.time_provider();
135
+ const window = this.options.window * 1e3;
136
+ const current_window = Math.floor(now / window);
137
+ const previous_window = current_window - 1;
138
+ const current_key = this.getKey(identifier, current_window.toString());
139
+ const previous_key = this.getKey(
140
+ identifier,
141
+ previous_window.toString()
142
+ );
143
+ const [remaining, retry_after] = await this.evalSlidingWindow(
144
+ current_key,
145
+ previous_key,
146
+ this.options.limit.toString(),
147
+ now.toString(),
148
+ window.toString(),
149
+ "1"
150
+ );
151
+ return {
152
+ success: remaining >= 0,
153
+ limit: this.options.limit,
154
+ remaining: Math.max(0, remaining),
155
+ retry_after,
156
+ reset: this.time_provider() + this.options.window * 2e3
157
+ };
158
+ }
159
+ async evalSlidingWindow(currentKey, previousKey, limit, now, window, increment) {
160
+ if (!this.client.script || !this.client.evalsha) {
161
+ throw new ConfigurationError(
162
+ "Sliding window algorithm requires a Redis client with script() and evalsha() support for Lua script execution. Please use a client like ioredis, iovalkey, or node-redis."
163
+ );
164
+ }
165
+ if (!this.scriptSha) {
166
+ this.scriptSha = await this.client.script(
167
+ "LOAD",
168
+ SLIDING_WINDOW_SCRIPT
169
+ );
170
+ }
171
+ try {
172
+ const result = await this.client.evalsha(
173
+ this.scriptSha,
174
+ 2,
175
+ currentKey,
176
+ previousKey,
177
+ limit,
178
+ now,
179
+ window,
180
+ increment
181
+ );
182
+ return result;
183
+ } catch (error) {
184
+ if (error instanceof Error && error.message.includes("NOSCRIPT")) {
185
+ this.scriptSha = await this.client.script(
186
+ "LOAD",
187
+ SLIDING_WINDOW_SCRIPT
188
+ );
189
+ const result = await this.client.evalsha(
190
+ this.scriptSha,
191
+ 2,
192
+ currentKey,
193
+ previousKey,
194
+ limit,
195
+ now,
196
+ window,
197
+ increment
198
+ );
199
+ return result;
200
+ }
201
+ throw error;
202
+ }
203
+ }
204
+ };
205
+
206
+ exports.ConfigurationError = ConfigurationError;
207
+ exports.Ratelimit = Ratelimit;
208
+ exports.RatelimitError = RatelimitError;
package/dist/index.mjs ADDED
@@ -0,0 +1,204 @@
1
+ // src/errors.ts
2
+ var ConfigurationError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "ConfigurationError";
6
+ }
7
+ };
8
+ var RatelimitError = class extends Error {
9
+ constructor(message, originalError) {
10
+ super(message);
11
+ this.originalError = originalError;
12
+ this.name = "RatelimitError";
13
+ this.stack = new Error().stack;
14
+ this.message = message;
15
+ this.originalError = originalError;
16
+ }
17
+ };
18
+
19
+ // src/client.ts
20
+ var SLIDING_WINDOW_SCRIPT = `
21
+ local current_key = KEYS[1]
22
+ local previous_key = KEYS[2]
23
+ local tokens = tonumber(ARGV[1])
24
+ local now = tonumber(ARGV[2])
25
+ local window = tonumber(ARGV[3])
26
+ local increment_by = tonumber(ARGV[4])
27
+
28
+ local current_count = tonumber(redis.call("GET", current_key) or "0")
29
+ local previous_count = tonumber(redis.call("GET", previous_key) or "0")
30
+
31
+ local time_in_current = now % window
32
+ local time_remaining_previous = window - time_in_current
33
+ local weighted_previous = (previous_count * time_remaining_previous) / window
34
+ local cumulative_count = math.floor(weighted_previous) + current_count + increment_by
35
+
36
+ if cumulative_count > tokens then
37
+ local needed = cumulative_count - tokens + increment_by
38
+ local retry_after = window - time_in_current
39
+
40
+ if previous_count > 0 then
41
+ local time_needed = (needed * window) / previous_count
42
+ retry_after = math.ceil(time_needed)
43
+
44
+ if retry_after > time_remaining_previous then
45
+ retry_after = time_remaining_previous
46
+ end
47
+ end
48
+
49
+ return { -1, retry_after }
50
+ end
51
+
52
+ current_count = current_count + increment_by
53
+ redis.call("SET", current_key, current_count)
54
+ redis.call("PEXPIRE", current_key, window * 2 + 1000)
55
+
56
+ return { tokens - (math.floor(weighted_previous) + current_count), 0 }
57
+ `;
58
+
59
+ // src/ratelimit.ts
60
+ var Ratelimit = class {
61
+ constructor(client, options, time_provider = Date.now) {
62
+ this.options = options;
63
+ this.time_provider = time_provider;
64
+ this.client = client;
65
+ this.validateOptions(options);
66
+ }
67
+ static fixedWindow(params) {
68
+ return { type: "fixed", ...params };
69
+ }
70
+ static slidingWindow(params) {
71
+ return { type: "sliding", ...params };
72
+ }
73
+ async limit(identifier) {
74
+ try {
75
+ return this.options.type === "fixed" ? await this.fixedWindowLimit(identifier) : await this.slidingWindowLimit(identifier);
76
+ } catch (error) {
77
+ if (error instanceof ConfigurationError) {
78
+ throw error;
79
+ }
80
+ throw new RatelimitError(
81
+ "Failed to check rate limit",
82
+ error instanceof Error ? error : new Error(String(error))
83
+ );
84
+ }
85
+ }
86
+ validateOptions(options) {
87
+ if (options.limit <= 0) {
88
+ throw new ConfigurationError("Limit must be greater than 0");
89
+ }
90
+ if (options.window <= 0) {
91
+ throw new ConfigurationError("Time window must be greater than 0");
92
+ }
93
+ if (options.type !== "fixed" && options.type !== "sliding") {
94
+ throw new ConfigurationError(
95
+ 'Type must be either "fixed" or "sliding"'
96
+ );
97
+ }
98
+ }
99
+ getKey(identifier, suffix) {
100
+ const prefix = this.options.prefix || "ratelimit";
101
+ return `${prefix}:${identifier}:${suffix}`;
102
+ }
103
+ async fixedWindowLimit(identifier) {
104
+ const now = this.time_provider();
105
+ const window_size = this.options.window;
106
+ const current_window = Math.floor(now / (window_size * 1e3));
107
+ const window_key = this.getKey(identifier, current_window.toString());
108
+ const window_end = (current_window + 1) * (window_size * 1e3);
109
+ const count = await this.client.incr(window_key);
110
+ if (count === 1) {
111
+ await this.client.expire(window_key, window_size);
112
+ }
113
+ if (count > this.options.limit) {
114
+ const ttl = await this.client.ttl(window_key);
115
+ return {
116
+ success: false,
117
+ limit: this.options.limit,
118
+ remaining: 0,
119
+ retry_after: Math.max(ttl * 1e3, 0),
120
+ reset: window_end
121
+ };
122
+ }
123
+ return {
124
+ success: true,
125
+ limit: this.options.limit,
126
+ remaining: this.options.limit - count,
127
+ retry_after: 0,
128
+ reset: window_end
129
+ };
130
+ }
131
+ async slidingWindowLimit(identifier) {
132
+ const now = this.time_provider();
133
+ const window = this.options.window * 1e3;
134
+ const current_window = Math.floor(now / window);
135
+ const previous_window = current_window - 1;
136
+ const current_key = this.getKey(identifier, current_window.toString());
137
+ const previous_key = this.getKey(
138
+ identifier,
139
+ previous_window.toString()
140
+ );
141
+ const [remaining, retry_after] = await this.evalSlidingWindow(
142
+ current_key,
143
+ previous_key,
144
+ this.options.limit.toString(),
145
+ now.toString(),
146
+ window.toString(),
147
+ "1"
148
+ );
149
+ return {
150
+ success: remaining >= 0,
151
+ limit: this.options.limit,
152
+ remaining: Math.max(0, remaining),
153
+ retry_after,
154
+ reset: this.time_provider() + this.options.window * 2e3
155
+ };
156
+ }
157
+ async evalSlidingWindow(currentKey, previousKey, limit, now, window, increment) {
158
+ if (!this.client.script || !this.client.evalsha) {
159
+ throw new ConfigurationError(
160
+ "Sliding window algorithm requires a Redis client with script() and evalsha() support for Lua script execution. Please use a client like ioredis, iovalkey, or node-redis."
161
+ );
162
+ }
163
+ if (!this.scriptSha) {
164
+ this.scriptSha = await this.client.script(
165
+ "LOAD",
166
+ SLIDING_WINDOW_SCRIPT
167
+ );
168
+ }
169
+ try {
170
+ const result = await this.client.evalsha(
171
+ this.scriptSha,
172
+ 2,
173
+ currentKey,
174
+ previousKey,
175
+ limit,
176
+ now,
177
+ window,
178
+ increment
179
+ );
180
+ return result;
181
+ } catch (error) {
182
+ if (error instanceof Error && error.message.includes("NOSCRIPT")) {
183
+ this.scriptSha = await this.client.script(
184
+ "LOAD",
185
+ SLIDING_WINDOW_SCRIPT
186
+ );
187
+ const result = await this.client.evalsha(
188
+ this.scriptSha,
189
+ 2,
190
+ currentKey,
191
+ previousKey,
192
+ limit,
193
+ now,
194
+ window,
195
+ increment
196
+ );
197
+ return result;
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ };
203
+
204
+ export { ConfigurationError, Ratelimit, RatelimitError };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "io-ratelimiter",
3
+ "version": "1.0.0",
4
+ "author": "Devhuset",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/kasperaamodt/io-ratelimiter.git"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.mjs",
11
+ "devDependencies": {
12
+ "@types/bun": "^1.3.5",
13
+ "@types/node": "^25.0.3",
14
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
15
+ "@typescript-eslint/parser": "^8.50.1",
16
+ "eslint": "^9.39.2",
17
+ "eslint-config-prettier": "^10.1.8",
18
+ "iovalkey": "^0.3.1",
19
+ "prettier": "^3.7.4",
20
+ "tsup": "^8.5.1",
21
+ "typescript": "^5.9.3",
22
+ "typescript-eslint": "^8.50.1"
23
+ },
24
+ "peerDependencies": {},
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "require": "./dist/index.js",
29
+ "import": "./dist/index.mjs"
30
+ }
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/kasperaamodt/io-ratelimiter/issues"
34
+ },
35
+ "description": "A flexible rate limiting library with support for fixed and sliding windows using Redis/Valkey",
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "homepage": "https://github.com/kasperaamodt/io-ratelimiter#readme",
42
+ "keywords": [
43
+ "rate-limit",
44
+ "valkey",
45
+ "redis",
46
+ "sliding-window",
47
+ "fixed-window",
48
+ "typescript",
49
+ "rate-limiting"
50
+ ],
51
+ "license": "MIT",
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "test": "bun test",
55
+ "lint": "eslint 'src/**/*.ts'",
56
+ "format": "prettier --write \"src/**/*.ts\""
57
+ },
58
+ "types": "./dist/index.d.ts"
59
+ }