@jellyfungus/hono-rate-limiter 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/README.md +183 -0
- package/dist/index.cjs +220 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +168 -0
- package/dist/index.d.ts +168 -0
- package/dist/index.js +193 -0
- package/dist/index.js.map +1 -0
- package/dist/store/cloudflare-kv.cjs +89 -0
- package/dist/store/cloudflare-kv.cjs.map +1 -0
- package/dist/store/cloudflare-kv.d.cts +67 -0
- package/dist/store/cloudflare-kv.d.ts +67 -0
- package/dist/store/cloudflare-kv.js +64 -0
- package/dist/store/cloudflare-kv.js.map +1 -0
- package/dist/store/redis.cjs +92 -0
- package/dist/store/redis.cjs.map +1 -0
- package/dist/store/redis.d.cts +59 -0
- package/dist/store/redis.d.ts +59 -0
- package/dist/store/redis.js +67 -0
- package/dist/store/redis.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/store/cloudflare-kv.ts
|
|
2
|
+
var CloudflareKVStore = class {
|
|
3
|
+
namespace;
|
|
4
|
+
prefix;
|
|
5
|
+
windowMs = 6e4;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.namespace = options.namespace;
|
|
8
|
+
this.prefix = options.prefix ?? "rl:";
|
|
9
|
+
}
|
|
10
|
+
init(windowMs) {
|
|
11
|
+
this.windowMs = windowMs;
|
|
12
|
+
}
|
|
13
|
+
async increment(key) {
|
|
14
|
+
const fullKey = `${this.prefix}${key}`;
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const ttlSeconds = Math.max(60, Math.ceil(this.windowMs / 1e3));
|
|
17
|
+
const existing = await this.namespace.get(fullKey, {
|
|
18
|
+
type: "json"
|
|
19
|
+
});
|
|
20
|
+
let count;
|
|
21
|
+
let reset;
|
|
22
|
+
if (!existing || existing.reset <= now) {
|
|
23
|
+
count = 1;
|
|
24
|
+
reset = now + this.windowMs;
|
|
25
|
+
} else {
|
|
26
|
+
count = existing.count + 1;
|
|
27
|
+
reset = existing.reset;
|
|
28
|
+
}
|
|
29
|
+
await this.namespace.put(fullKey, JSON.stringify({ count, reset }), {
|
|
30
|
+
expirationTtl: ttlSeconds
|
|
31
|
+
});
|
|
32
|
+
return { count, reset };
|
|
33
|
+
}
|
|
34
|
+
async get(key) {
|
|
35
|
+
const fullKey = `${this.prefix}${key}`;
|
|
36
|
+
const data = await this.namespace.get(fullKey, { type: "json" });
|
|
37
|
+
if (!data || data.reset <= Date.now()) {
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
return { count: data.count, reset: data.reset };
|
|
41
|
+
}
|
|
42
|
+
async decrement(key) {
|
|
43
|
+
const fullKey = `${this.prefix}${key}`;
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const existing = await this.namespace.get(fullKey, {
|
|
46
|
+
type: "json"
|
|
47
|
+
});
|
|
48
|
+
if (existing && existing.count > 0 && existing.reset > now) {
|
|
49
|
+
const ttlSeconds = Math.max(60, Math.ceil((existing.reset - now) / 1e3));
|
|
50
|
+
await this.namespace.put(
|
|
51
|
+
fullKey,
|
|
52
|
+
JSON.stringify({ count: existing.count - 1, reset: existing.reset }),
|
|
53
|
+
{ expirationTtl: ttlSeconds }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async resetKey(key) {
|
|
58
|
+
await this.namespace.delete(`${this.prefix}${key}`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
export {
|
|
62
|
+
CloudflareKVStore
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=cloudflare-kv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/store/cloudflare-kv.ts"],"sourcesContent":["/**\n * Cloudflare KV store for rate limiting.\n *\n * Note: KV is eventually consistent (~60s propagation).\n * For strict rate limiting, consider Durable Objects.\n */\n\nimport type { RateLimitStore, StoreResult } from \"../index\";\n\n/**\n * Cloudflare KV Namespace interface\n */\nexport type KVNamespace = {\n get: <T = string>(\n key: string,\n options?: { type: \"json\" },\n ) => Promise<T | null>;\n put: (\n key: string,\n value: string,\n options?: { expirationTtl?: number },\n ) => Promise<void>;\n delete: (key: string) => Promise<void>;\n};\n\n/**\n * Options for Cloudflare KV store\n */\nexport type CloudflareKVStoreOptions = {\n /**\n * KV Namespace binding\n */\n namespace: KVNamespace;\n\n /**\n * Key prefix for rate limit entries\n * @default 'rl:'\n */\n prefix?: string;\n};\n\ntype KVEntry = {\n count: number;\n reset: number;\n};\n\n/**\n * Cloudflare KV store for rate limiting in Workers.\n *\n * @example\n * ```ts\n * import { rateLimiter } from 'hono-rate-limit'\n * import { CloudflareKVStore } from 'hono-rate-limit/store/cloudflare-kv'\n *\n * type Bindings = { RATE_LIMIT_KV: KVNamespace }\n *\n * app.use('*', async (c, next) => {\n * const limiter = rateLimiter({\n * store: new CloudflareKVStore({ namespace: c.env.RATE_LIMIT_KV }),\n * })\n * return limiter(c, next)\n * })\n * ```\n */\nexport class CloudflareKVStore implements RateLimitStore {\n private namespace: KVNamespace;\n private prefix: string;\n private windowMs = 60_000;\n\n constructor(options: CloudflareKVStoreOptions) {\n this.namespace = options.namespace;\n this.prefix = options.prefix ?? \"rl:\";\n }\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n }\n\n async increment(key: string): Promise<StoreResult> {\n const fullKey = `${this.prefix}${key}`;\n const now = Date.now();\n\n // KV minimum TTL is 60 seconds\n const ttlSeconds = Math.max(60, Math.ceil(this.windowMs / 1000));\n\n const existing = await this.namespace.get<KVEntry>(fullKey, {\n type: \"json\",\n });\n\n let count: number;\n let reset: number;\n\n if (!existing || existing.reset <= now) {\n // New window\n count = 1;\n reset = now + this.windowMs;\n } else {\n // Increment\n count = existing.count + 1;\n reset = existing.reset;\n }\n\n await this.namespace.put(fullKey, JSON.stringify({ count, reset }), {\n expirationTtl: ttlSeconds,\n });\n\n return { count, reset };\n }\n\n async get(key: string): Promise<StoreResult | undefined> {\n const fullKey = `${this.prefix}${key}`;\n const data = await this.namespace.get<KVEntry>(fullKey, { type: \"json\" });\n\n if (!data || data.reset <= Date.now()) {\n return undefined;\n }\n\n return { count: data.count, reset: data.reset };\n }\n\n async decrement(key: string): Promise<void> {\n const fullKey = `${this.prefix}${key}`;\n const now = Date.now();\n\n const existing = await this.namespace.get<KVEntry>(fullKey, {\n type: \"json\",\n });\n\n if (existing && existing.count > 0 && existing.reset > now) {\n const ttlSeconds = Math.max(60, Math.ceil((existing.reset - now) / 1000));\n await this.namespace.put(\n fullKey,\n JSON.stringify({ count: existing.count - 1, reset: existing.reset }),\n { expirationTtl: ttlSeconds },\n );\n }\n }\n\n async resetKey(key: string): Promise<void> {\n await this.namespace.delete(`${this.prefix}${key}`);\n }\n}\n"],"mappings":";AAgEO,IAAM,oBAAN,MAAkD;AAAA,EAC/C;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EAEnB,YAAY,SAAmC;AAC7C,SAAK,YAAY,QAAQ;AACzB,SAAK,SAAS,QAAQ,UAAU;AAAA,EAClC;AAAA,EAEA,KAAK,UAAwB;AAC3B,SAAK,WAAW;AAAA,EAClB;AAAA,EAEA,MAAM,UAAU,KAAmC;AACjD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI;AAGrB,UAAM,aAAa,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,WAAW,GAAI,CAAC;AAE/D,UAAM,WAAW,MAAM,KAAK,UAAU,IAAa,SAAS;AAAA,MAC1D,MAAM;AAAA,IACR,CAAC;AAED,QAAI;AACJ,QAAI;AAEJ,QAAI,CAAC,YAAY,SAAS,SAAS,KAAK;AAEtC,cAAQ;AACR,cAAQ,MAAM,KAAK;AAAA,IACrB,OAAO;AAEL,cAAQ,SAAS,QAAQ;AACzB,cAAQ,SAAS;AAAA,IACnB;AAEA,UAAM,KAAK,UAAU,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,CAAC,GAAG;AAAA,MAClE,eAAe;AAAA,IACjB,CAAC;AAED,WAAO,EAAE,OAAO,MAAM;AAAA,EACxB;AAAA,EAEA,MAAM,IAAI,KAA+C;AACvD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,OAAO,MAAM,KAAK,UAAU,IAAa,SAAS,EAAE,MAAM,OAAO,CAAC;AAExE,QAAI,CAAC,QAAQ,KAAK,SAAS,KAAK,IAAI,GAAG;AACrC,aAAO;AAAA,IACT;AAEA,WAAO,EAAE,OAAO,KAAK,OAAO,OAAO,KAAK,MAAM;AAAA,EAChD;AAAA,EAEA,MAAM,UAAU,KAA4B;AAC1C,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI;AAErB,UAAM,WAAW,MAAM,KAAK,UAAU,IAAa,SAAS;AAAA,MAC1D,MAAM;AAAA,IACR,CAAC;AAED,QAAI,YAAY,SAAS,QAAQ,KAAK,SAAS,QAAQ,KAAK;AAC1D,YAAM,aAAa,KAAK,IAAI,IAAI,KAAK,MAAM,SAAS,QAAQ,OAAO,GAAI,CAAC;AACxE,YAAM,KAAK,UAAU;AAAA,QACnB;AAAA,QACA,KAAK,UAAU,EAAE,OAAO,SAAS,QAAQ,GAAG,OAAO,SAAS,MAAM,CAAC;AAAA,QACnE,EAAE,eAAe,WAAW;AAAA,MAC9B;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,KAA4B;AACzC,UAAM,KAAK,UAAU,OAAO,GAAG,KAAK,MAAM,GAAG,GAAG,EAAE;AAAA,EACpD;AACF;","names":[]}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/store/redis.ts
|
|
21
|
+
var redis_exports = {};
|
|
22
|
+
__export(redis_exports, {
|
|
23
|
+
RedisStore: () => RedisStore
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(redis_exports);
|
|
26
|
+
var INCR_SCRIPT = `
|
|
27
|
+
local key = KEYS[1]
|
|
28
|
+
local window = tonumber(ARGV[1])
|
|
29
|
+
local now = tonumber(ARGV[2])
|
|
30
|
+
|
|
31
|
+
local count = redis.call('INCR', key)
|
|
32
|
+
if count == 1 then
|
|
33
|
+
redis.call('PEXPIRE', key, window)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local ttl = redis.call('PTTL', key)
|
|
37
|
+
local reset = now + ttl
|
|
38
|
+
|
|
39
|
+
return {count, reset}
|
|
40
|
+
`;
|
|
41
|
+
var RedisStore = class {
|
|
42
|
+
client;
|
|
43
|
+
prefix;
|
|
44
|
+
windowMs = 6e4;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.client = options.client;
|
|
47
|
+
this.prefix = options.prefix ?? "rl:";
|
|
48
|
+
}
|
|
49
|
+
init(windowMs) {
|
|
50
|
+
this.windowMs = windowMs;
|
|
51
|
+
}
|
|
52
|
+
async increment(key) {
|
|
53
|
+
const fullKey = `${this.prefix}${key}`;
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const result = await this.client.eval(
|
|
56
|
+
INCR_SCRIPT,
|
|
57
|
+
[fullKey],
|
|
58
|
+
[this.windowMs, now]
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
count: result[0],
|
|
62
|
+
reset: result[1]
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async get(key) {
|
|
66
|
+
const fullKey = `${this.prefix}${key}`;
|
|
67
|
+
const value = await this.client.get(fullKey);
|
|
68
|
+
if (!value) {
|
|
69
|
+
return void 0;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
count: parseInt(value, 10),
|
|
73
|
+
reset: Date.now() + this.windowMs
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async decrement(key) {
|
|
77
|
+
const fullKey = `${this.prefix}${key}`;
|
|
78
|
+
await this.client.eval(
|
|
79
|
+
'local count = redis.call("GET", KEYS[1]); if count and tonumber(count) > 0 then redis.call("DECR", KEYS[1]) end',
|
|
80
|
+
[fullKey],
|
|
81
|
+
[]
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
async resetKey(key) {
|
|
85
|
+
await this.client.del(`${this.prefix}${key}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
89
|
+
0 && (module.exports = {
|
|
90
|
+
RedisStore
|
|
91
|
+
});
|
|
92
|
+
//# sourceMappingURL=redis.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/store/redis.ts"],"sourcesContent":["/**\n * Redis store for rate limiting.\n * Compatible with ioredis, @upstash/redis, and similar clients.\n */\n\nimport type { RateLimitStore, StoreResult } from \"../index\";\n\n/**\n * Redis client interface\n */\nexport type RedisClient = {\n eval: (\n script: string,\n keys: string[],\n args: (string | number)[],\n ) => Promise<unknown> | unknown;\n get: (key: string) => Promise<string | null> | string | null;\n del: (...keys: string[]) => Promise<number> | number;\n};\n\n/**\n * Options for Redis store\n */\nexport type RedisStoreOptions = {\n /**\n * Redis client instance\n */\n client: RedisClient;\n\n /**\n * Key prefix for rate limit entries\n * @default 'rl:'\n */\n prefix?: string;\n};\n\n// Lua script for atomic increment with expiry\nconst INCR_SCRIPT = `\nlocal key = KEYS[1]\nlocal window = tonumber(ARGV[1])\nlocal now = tonumber(ARGV[2])\n\nlocal count = redis.call('INCR', key)\nif count == 1 then\n redis.call('PEXPIRE', key, window)\nend\n\nlocal ttl = redis.call('PTTL', key)\nlocal reset = now + ttl\n\nreturn {count, reset}\n`;\n\n/**\n * Redis store for distributed rate limiting.\n *\n * @example\n * ```ts\n * import { rateLimiter } from 'hono-rate-limit'\n * import { RedisStore } from 'hono-rate-limit/store/redis'\n * import Redis from 'ioredis'\n *\n * const redis = new Redis(process.env.REDIS_URL)\n *\n * app.use(rateLimiter({\n * store: new RedisStore({ client: redis }),\n * }))\n * ```\n */\nexport class RedisStore implements RateLimitStore {\n private client: RedisClient;\n private prefix: string;\n private windowMs = 60_000;\n\n constructor(options: RedisStoreOptions) {\n this.client = options.client;\n this.prefix = options.prefix ?? \"rl:\";\n }\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n }\n\n async increment(key: string): Promise<StoreResult> {\n const fullKey = `${this.prefix}${key}`;\n const now = Date.now();\n\n const result = (await this.client.eval(\n INCR_SCRIPT,\n [fullKey],\n [this.windowMs, now],\n )) as [number, number];\n\n return {\n count: result[0],\n reset: result[1],\n };\n }\n\n async get(key: string): Promise<StoreResult | undefined> {\n const fullKey = `${this.prefix}${key}`;\n const value = await this.client.get(fullKey);\n\n if (!value) {\n return undefined;\n }\n\n return {\n count: parseInt(value, 10),\n reset: Date.now() + this.windowMs,\n };\n }\n\n async decrement(key: string): Promise<void> {\n const fullKey = `${this.prefix}${key}`;\n await this.client.eval(\n 'local count = redis.call(\"GET\", KEYS[1]); if count and tonumber(count) > 0 then redis.call(\"DECR\", KEYS[1]) end',\n [fullKey],\n [],\n );\n }\n\n async resetKey(key: string): Promise<void> {\n await this.client.del(`${this.prefix}${key}`);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAqCA,IAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgCb,IAAM,aAAN,MAA2C;AAAA,EACxC;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EAEnB,YAAY,SAA4B;AACtC,SAAK,SAAS,QAAQ;AACtB,SAAK,SAAS,QAAQ,UAAU;AAAA,EAClC;AAAA,EAEA,KAAK,UAAwB;AAC3B,SAAK,WAAW;AAAA,EAClB;AAAA,EAEA,MAAM,UAAU,KAAmC;AACjD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI;AAErB,UAAM,SAAU,MAAM,KAAK,OAAO;AAAA,MAChC;AAAA,MACA,CAAC,OAAO;AAAA,MACR,CAAC,KAAK,UAAU,GAAG;AAAA,IACrB;AAEA,WAAO;AAAA,MACL,OAAO,OAAO,CAAC;AAAA,MACf,OAAO,OAAO,CAAC;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,KAA+C;AACvD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,QAAQ,MAAM,KAAK,OAAO,IAAI,OAAO;AAE3C,QAAI,CAAC,OAAO;AACV,aAAO;AAAA,IACT;AAEA,WAAO;AAAA,MACL,OAAO,SAAS,OAAO,EAAE;AAAA,MACzB,OAAO,KAAK,IAAI,IAAI,KAAK;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,KAA4B;AAC1C,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,KAAK,OAAO;AAAA,MAChB;AAAA,MACA,CAAC,OAAO;AAAA,MACR,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,KAA4B;AACzC,UAAM,KAAK,OAAO,IAAI,GAAG,KAAK,MAAM,GAAG,GAAG,EAAE;AAAA,EAC9C;AACF;","names":[]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RateLimitStore, StoreResult } from '../index.cjs';
|
|
2
|
+
import 'hono';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redis store for rate limiting.
|
|
6
|
+
* Compatible with ioredis, @upstash/redis, and similar clients.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redis client interface
|
|
11
|
+
*/
|
|
12
|
+
type RedisClient = {
|
|
13
|
+
eval: (script: string, keys: string[], args: (string | number)[]) => Promise<unknown> | unknown;
|
|
14
|
+
get: (key: string) => Promise<string | null> | string | null;
|
|
15
|
+
del: (...keys: string[]) => Promise<number> | number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Options for Redis store
|
|
19
|
+
*/
|
|
20
|
+
type RedisStoreOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* Redis client instance
|
|
23
|
+
*/
|
|
24
|
+
client: RedisClient;
|
|
25
|
+
/**
|
|
26
|
+
* Key prefix for rate limit entries
|
|
27
|
+
* @default 'rl:'
|
|
28
|
+
*/
|
|
29
|
+
prefix?: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Redis store for distributed rate limiting.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { rateLimiter } from 'hono-rate-limit'
|
|
37
|
+
* import { RedisStore } from 'hono-rate-limit/store/redis'
|
|
38
|
+
* import Redis from 'ioredis'
|
|
39
|
+
*
|
|
40
|
+
* const redis = new Redis(process.env.REDIS_URL)
|
|
41
|
+
*
|
|
42
|
+
* app.use(rateLimiter({
|
|
43
|
+
* store: new RedisStore({ client: redis }),
|
|
44
|
+
* }))
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
declare class RedisStore implements RateLimitStore {
|
|
48
|
+
private client;
|
|
49
|
+
private prefix;
|
|
50
|
+
private windowMs;
|
|
51
|
+
constructor(options: RedisStoreOptions);
|
|
52
|
+
init(windowMs: number): void;
|
|
53
|
+
increment(key: string): Promise<StoreResult>;
|
|
54
|
+
get(key: string): Promise<StoreResult | undefined>;
|
|
55
|
+
decrement(key: string): Promise<void>;
|
|
56
|
+
resetKey(key: string): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { type RedisClient, RedisStore, type RedisStoreOptions };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RateLimitStore, StoreResult } from '../index.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redis store for rate limiting.
|
|
6
|
+
* Compatible with ioredis, @upstash/redis, and similar clients.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redis client interface
|
|
11
|
+
*/
|
|
12
|
+
type RedisClient = {
|
|
13
|
+
eval: (script: string, keys: string[], args: (string | number)[]) => Promise<unknown> | unknown;
|
|
14
|
+
get: (key: string) => Promise<string | null> | string | null;
|
|
15
|
+
del: (...keys: string[]) => Promise<number> | number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Options for Redis store
|
|
19
|
+
*/
|
|
20
|
+
type RedisStoreOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* Redis client instance
|
|
23
|
+
*/
|
|
24
|
+
client: RedisClient;
|
|
25
|
+
/**
|
|
26
|
+
* Key prefix for rate limit entries
|
|
27
|
+
* @default 'rl:'
|
|
28
|
+
*/
|
|
29
|
+
prefix?: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Redis store for distributed rate limiting.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { rateLimiter } from 'hono-rate-limit'
|
|
37
|
+
* import { RedisStore } from 'hono-rate-limit/store/redis'
|
|
38
|
+
* import Redis from 'ioredis'
|
|
39
|
+
*
|
|
40
|
+
* const redis = new Redis(process.env.REDIS_URL)
|
|
41
|
+
*
|
|
42
|
+
* app.use(rateLimiter({
|
|
43
|
+
* store: new RedisStore({ client: redis }),
|
|
44
|
+
* }))
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
declare class RedisStore implements RateLimitStore {
|
|
48
|
+
private client;
|
|
49
|
+
private prefix;
|
|
50
|
+
private windowMs;
|
|
51
|
+
constructor(options: RedisStoreOptions);
|
|
52
|
+
init(windowMs: number): void;
|
|
53
|
+
increment(key: string): Promise<StoreResult>;
|
|
54
|
+
get(key: string): Promise<StoreResult | undefined>;
|
|
55
|
+
decrement(key: string): Promise<void>;
|
|
56
|
+
resetKey(key: string): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { type RedisClient, RedisStore, type RedisStoreOptions };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/store/redis.ts
|
|
2
|
+
var INCR_SCRIPT = `
|
|
3
|
+
local key = KEYS[1]
|
|
4
|
+
local window = tonumber(ARGV[1])
|
|
5
|
+
local now = tonumber(ARGV[2])
|
|
6
|
+
|
|
7
|
+
local count = redis.call('INCR', key)
|
|
8
|
+
if count == 1 then
|
|
9
|
+
redis.call('PEXPIRE', key, window)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
local ttl = redis.call('PTTL', key)
|
|
13
|
+
local reset = now + ttl
|
|
14
|
+
|
|
15
|
+
return {count, reset}
|
|
16
|
+
`;
|
|
17
|
+
var RedisStore = class {
|
|
18
|
+
client;
|
|
19
|
+
prefix;
|
|
20
|
+
windowMs = 6e4;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.client = options.client;
|
|
23
|
+
this.prefix = options.prefix ?? "rl:";
|
|
24
|
+
}
|
|
25
|
+
init(windowMs) {
|
|
26
|
+
this.windowMs = windowMs;
|
|
27
|
+
}
|
|
28
|
+
async increment(key) {
|
|
29
|
+
const fullKey = `${this.prefix}${key}`;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const result = await this.client.eval(
|
|
32
|
+
INCR_SCRIPT,
|
|
33
|
+
[fullKey],
|
|
34
|
+
[this.windowMs, now]
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
count: result[0],
|
|
38
|
+
reset: result[1]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async get(key) {
|
|
42
|
+
const fullKey = `${this.prefix}${key}`;
|
|
43
|
+
const value = await this.client.get(fullKey);
|
|
44
|
+
if (!value) {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
count: parseInt(value, 10),
|
|
49
|
+
reset: Date.now() + this.windowMs
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async decrement(key) {
|
|
53
|
+
const fullKey = `${this.prefix}${key}`;
|
|
54
|
+
await this.client.eval(
|
|
55
|
+
'local count = redis.call("GET", KEYS[1]); if count and tonumber(count) > 0 then redis.call("DECR", KEYS[1]) end',
|
|
56
|
+
[fullKey],
|
|
57
|
+
[]
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
async resetKey(key) {
|
|
61
|
+
await this.client.del(`${this.prefix}${key}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
export {
|
|
65
|
+
RedisStore
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=redis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/store/redis.ts"],"sourcesContent":["/**\n * Redis store for rate limiting.\n * Compatible with ioredis, @upstash/redis, and similar clients.\n */\n\nimport type { RateLimitStore, StoreResult } from \"../index\";\n\n/**\n * Redis client interface\n */\nexport type RedisClient = {\n eval: (\n script: string,\n keys: string[],\n args: (string | number)[],\n ) => Promise<unknown> | unknown;\n get: (key: string) => Promise<string | null> | string | null;\n del: (...keys: string[]) => Promise<number> | number;\n};\n\n/**\n * Options for Redis store\n */\nexport type RedisStoreOptions = {\n /**\n * Redis client instance\n */\n client: RedisClient;\n\n /**\n * Key prefix for rate limit entries\n * @default 'rl:'\n */\n prefix?: string;\n};\n\n// Lua script for atomic increment with expiry\nconst INCR_SCRIPT = `\nlocal key = KEYS[1]\nlocal window = tonumber(ARGV[1])\nlocal now = tonumber(ARGV[2])\n\nlocal count = redis.call('INCR', key)\nif count == 1 then\n redis.call('PEXPIRE', key, window)\nend\n\nlocal ttl = redis.call('PTTL', key)\nlocal reset = now + ttl\n\nreturn {count, reset}\n`;\n\n/**\n * Redis store for distributed rate limiting.\n *\n * @example\n * ```ts\n * import { rateLimiter } from 'hono-rate-limit'\n * import { RedisStore } from 'hono-rate-limit/store/redis'\n * import Redis from 'ioredis'\n *\n * const redis = new Redis(process.env.REDIS_URL)\n *\n * app.use(rateLimiter({\n * store: new RedisStore({ client: redis }),\n * }))\n * ```\n */\nexport class RedisStore implements RateLimitStore {\n private client: RedisClient;\n private prefix: string;\n private windowMs = 60_000;\n\n constructor(options: RedisStoreOptions) {\n this.client = options.client;\n this.prefix = options.prefix ?? \"rl:\";\n }\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n }\n\n async increment(key: string): Promise<StoreResult> {\n const fullKey = `${this.prefix}${key}`;\n const now = Date.now();\n\n const result = (await this.client.eval(\n INCR_SCRIPT,\n [fullKey],\n [this.windowMs, now],\n )) as [number, number];\n\n return {\n count: result[0],\n reset: result[1],\n };\n }\n\n async get(key: string): Promise<StoreResult | undefined> {\n const fullKey = `${this.prefix}${key}`;\n const value = await this.client.get(fullKey);\n\n if (!value) {\n return undefined;\n }\n\n return {\n count: parseInt(value, 10),\n reset: Date.now() + this.windowMs,\n };\n }\n\n async decrement(key: string): Promise<void> {\n const fullKey = `${this.prefix}${key}`;\n await this.client.eval(\n 'local count = redis.call(\"GET\", KEYS[1]); if count and tonumber(count) > 0 then redis.call(\"DECR\", KEYS[1]) end',\n [fullKey],\n [],\n );\n }\n\n async resetKey(key: string): Promise<void> {\n await this.client.del(`${this.prefix}${key}`);\n }\n}\n"],"mappings":";AAqCA,IAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgCb,IAAM,aAAN,MAA2C;AAAA,EACxC;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EAEnB,YAAY,SAA4B;AACtC,SAAK,SAAS,QAAQ;AACtB,SAAK,SAAS,QAAQ,UAAU;AAAA,EAClC;AAAA,EAEA,KAAK,UAAwB;AAC3B,SAAK,WAAW;AAAA,EAClB;AAAA,EAEA,MAAM,UAAU,KAAmC;AACjD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI;AAErB,UAAM,SAAU,MAAM,KAAK,OAAO;AAAA,MAChC;AAAA,MACA,CAAC,OAAO;AAAA,MACR,CAAC,KAAK,UAAU,GAAG;AAAA,IACrB;AAEA,WAAO;AAAA,MACL,OAAO,OAAO,CAAC;AAAA,MACf,OAAO,OAAO,CAAC;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,KAA+C;AACvD,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,QAAQ,MAAM,KAAK,OAAO,IAAI,OAAO;AAE3C,QAAI,CAAC,OAAO;AACV,aAAO;AAAA,IACT;AAEA,WAAO;AAAA,MACL,OAAO,SAAS,OAAO,EAAE;AAAA,MACzB,OAAO,KAAK,IAAI,IAAI,KAAK;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,KAA4B;AAC1C,UAAM,UAAU,GAAG,KAAK,MAAM,GAAG,GAAG;AACpC,UAAM,KAAK,OAAO;AAAA,MAChB;AAAA,MACA,CAAC,OAAO;AAAA,MACR,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,KAA4B;AACzC,UAAM,KAAK,OAAO,IAAI,GAAG,KAAK,MAAM,GAAG,GAAG,EAAE;AAAA,EAC9C;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jellyfungus/hono-rate-limiter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rate limiting middleware for Hono web framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./store/redis": {
|
|
16
|
+
"types": "./dist/store/redis.d.ts",
|
|
17
|
+
"import": "./dist/store/redis.js",
|
|
18
|
+
"require": "./dist/store/redis.cjs"
|
|
19
|
+
},
|
|
20
|
+
"./store/cloudflare-kv": {
|
|
21
|
+
"types": "./dist/store/cloudflare-kv.d.ts",
|
|
22
|
+
"import": "./dist/store/cloudflare-kv.js",
|
|
23
|
+
"require": "./dist/store/cloudflare-kv.cjs"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"hono",
|
|
38
|
+
"rate-limit",
|
|
39
|
+
"rate-limiter",
|
|
40
|
+
"middleware",
|
|
41
|
+
"api",
|
|
42
|
+
"cloudflare",
|
|
43
|
+
"workers",
|
|
44
|
+
"deno",
|
|
45
|
+
"bun",
|
|
46
|
+
"node"
|
|
47
|
+
],
|
|
48
|
+
"author": "",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/rokasta12/hono-rate-limiter.git"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"hono": ">=4.0.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^25.0.10",
|
|
59
|
+
"hono": "^4.6.0",
|
|
60
|
+
"tsup": "^8.0.0",
|
|
61
|
+
"typescript": "^5.0.0",
|
|
62
|
+
"vitest": "^2.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|