@mezzanine-stack/ratelimit-kv 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 +55 -0
- package/package.json +28 -0
- package/src/index.test.ts +44 -0
- package/src/index.ts +78 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @mezzanine-stack/ratelimit-kv
|
|
2
|
+
|
|
3
|
+
Cloudflare KV を使う `RateLimitAdapter` の実装。2 窓(1 分 / 10 分)のレート制限を IP ベースで行います。
|
|
4
|
+
|
|
5
|
+
## 使い方
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { KvRateLimitAdapter } from "@mezzanine-stack/ratelimit-kv";
|
|
9
|
+
|
|
10
|
+
const limiter = new KvRateLimitAdapter({
|
|
11
|
+
kv: env.RATE_LIMIT_KV, // KVNamespace binding
|
|
12
|
+
perMinute: 5,
|
|
13
|
+
per10Minutes: 20,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const result = await limiter.check(ip, ua);
|
|
17
|
+
if (!result.allowed) {
|
|
18
|
+
// result.retryAfterSeconds には推奨待機秒数が入る
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 戻り値 (`RateLimitResult`)
|
|
23
|
+
|
|
24
|
+
| フィールド | 型 | 説明 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `allowed` | `boolean` | リクエストを許可するか |
|
|
27
|
+
| `retryAfterSeconds` | `number?` | 超過した窓に応じた待機秒数 (60 または 600) |
|
|
28
|
+
|
|
29
|
+
## KV binding のセットアップ
|
|
30
|
+
|
|
31
|
+
`wrangler.toml` に KV namespace を設定します:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
[[kv_namespaces]]
|
|
35
|
+
binding = "RATE_LIMIT_KV"
|
|
36
|
+
id = "YOUR_KV_NAMESPACE_ID"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
KV namespace の作成:
|
|
40
|
+
```bash
|
|
41
|
+
wrangler kv namespace create "RATE_LIMIT_KV"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 設計上の注意
|
|
45
|
+
|
|
46
|
+
- Cloudflare KV は **eventually consistent** です。高トラフィック下では複数 edge に同時にリクエストが届くと、カウントが若干低く見積もられる場合があります。これはベストエフォートの防御として許容しています。
|
|
47
|
+
- キーは `rl:{ip}:{ua_hash}:m:{bucket}` / `rl:{ip}:{ua_hash}:t:{bucket}` 形式。UA ハッシュで NAT 背後の複数ユーザーを軽く識別します。
|
|
48
|
+
- TTL は 1 分窓 90 秒、10 分窓 700 秒で設定され、古いエントリは自動削除されます。
|
|
49
|
+
|
|
50
|
+
## 環境変数 (wrangler.toml)
|
|
51
|
+
|
|
52
|
+
| 変数名 | デフォルト | 説明 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `RATE_PER_MINUTE` | `5` | 1 分あたり上限リクエスト数 |
|
|
55
|
+
| `RATE_PER_10_MINUTES` | `20` | 10 分あたり上限リクエスト数 |
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mezzanine-stack/ratelimit-kv",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"types": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@mezzanine-stack/contact-core": "0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@cloudflare/workers-types": "^4.20260312.1",
|
|
21
|
+
"typescript": "^5.7.3",
|
|
22
|
+
"vitest": "^3.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { KvRateLimitAdapter } from "./index.js";
|
|
3
|
+
|
|
4
|
+
function createMockKv(initial: Record<string, string> = {}) {
|
|
5
|
+
const store = new Map(Object.entries(initial));
|
|
6
|
+
return {
|
|
7
|
+
store,
|
|
8
|
+
kv: {
|
|
9
|
+
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
|
10
|
+
put: vi.fn(async (key: string, value: string) => {
|
|
11
|
+
store.set(key, value);
|
|
12
|
+
}),
|
|
13
|
+
} as unknown as KVNamespace,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("KvRateLimitAdapter", () => {
|
|
18
|
+
it("allows first request and blocks second when perMinute is 1", async () => {
|
|
19
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
20
|
+
const { kv } = createMockKv();
|
|
21
|
+
const adapter = new KvRateLimitAdapter({ kv, perMinute: 1, per10Minutes: 10 });
|
|
22
|
+
|
|
23
|
+
const first = await adapter.check("203.0.113.1", "ua");
|
|
24
|
+
const second = await adapter.check("203.0.113.1", "ua");
|
|
25
|
+
|
|
26
|
+
expect(first).toEqual({ allowed: true });
|
|
27
|
+
expect(second).toEqual({ allowed: false, retryAfterSeconds: 60 });
|
|
28
|
+
nowSpy.mockRestore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("clamps perMinute lower bound to 1", async () => {
|
|
32
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
33
|
+
const { kv } = createMockKv();
|
|
34
|
+
const adapter = new KvRateLimitAdapter({ kv, perMinute: 0, per10Minutes: 10 });
|
|
35
|
+
|
|
36
|
+
const first = await adapter.check("203.0.113.2", "ua");
|
|
37
|
+
const second = await adapter.check("203.0.113.2", "ua");
|
|
38
|
+
|
|
39
|
+
expect(first.allowed).toBe(true);
|
|
40
|
+
expect(second.allowed).toBe(false);
|
|
41
|
+
nowSpy.mockRestore();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { RateLimitAdapter, RateLimitResult } from "@mezzanine-stack/contact-core";
|
|
2
|
+
|
|
3
|
+
export interface KvRateLimitAdapterOptions {
|
|
4
|
+
kv: KVNamespace;
|
|
5
|
+
/** Max requests per minute per IP. Default: 5 */
|
|
6
|
+
perMinute?: number;
|
|
7
|
+
/** Max requests per 10 minutes per IP. Default: 20 */
|
|
8
|
+
per10Minutes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two-window rate limiter backed by Cloudflare KV.
|
|
13
|
+
*
|
|
14
|
+
* Uses a cheap FNV-1a hash of the User-Agent to slightly differentiate NAT peers
|
|
15
|
+
* sharing the same IP address.
|
|
16
|
+
*
|
|
17
|
+
* Note: Cloudflare KV has eventual consistency. Under very high concurrency,
|
|
18
|
+
* the count may be slightly under-counted. This is an accepted trade-off for the
|
|
19
|
+
* low cost and operational simplicity — the rate limiter is a best-effort defence,
|
|
20
|
+
* not a strict quota system.
|
|
21
|
+
*/
|
|
22
|
+
export class KvRateLimitAdapter implements RateLimitAdapter {
|
|
23
|
+
readonly #kv: KVNamespace;
|
|
24
|
+
readonly #perMinute: number;
|
|
25
|
+
readonly #per10: number;
|
|
26
|
+
|
|
27
|
+
constructor(options: KvRateLimitAdapterOptions) {
|
|
28
|
+
this.#kv = options.kv;
|
|
29
|
+
this.#perMinute = clamp(options.perMinute ?? 5, 1, 60);
|
|
30
|
+
this.#per10 = clamp(options.per10Minutes ?? 20, 1, 300);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async check(ip: string, ua: string): Promise<RateLimitResult> {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const mBucket = Math.floor(now / 60_000);
|
|
36
|
+
const tBucket = Math.floor(now / 600_000);
|
|
37
|
+
const uaKey = simpleHash(ua).slice(0, 8);
|
|
38
|
+
|
|
39
|
+
const k1 = `rl:${ip}:${uaKey}:m:${mBucket}`;
|
|
40
|
+
const k2 = `rl:${ip}:${uaKey}:t:${tBucket}`;
|
|
41
|
+
|
|
42
|
+
const [c1, c2] = await Promise.all([getCount(this.#kv, k1), getCount(this.#kv, k2)]);
|
|
43
|
+
|
|
44
|
+
if (c1 >= this.#perMinute) {
|
|
45
|
+
return { allowed: false, retryAfterSeconds: 60 };
|
|
46
|
+
}
|
|
47
|
+
if (c2 >= this.#per10) {
|
|
48
|
+
return { allowed: false, retryAfterSeconds: 600 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await Promise.all([
|
|
52
|
+
this.#kv.put(k1, String(c1 + 1), { expirationTtl: 90 }),
|
|
53
|
+
this.#kv.put(k2, String(c2 + 1), { expirationTtl: 700 }),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
return { allowed: true };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getCount(kv: KVNamespace, key: string): Promise<number> {
|
|
61
|
+
const v = await kv.get(key);
|
|
62
|
+
const n = Number(v ?? "0");
|
|
63
|
+
return Number.isFinite(n) ? n : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clamp(value: number, min: number, max: number): number {
|
|
67
|
+
return Math.max(min, Math.min(max, value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** FNV-1a 32-bit hash (non-cryptographic, cheap key stabilizer). */
|
|
71
|
+
function simpleHash(s: string): string {
|
|
72
|
+
let h = 2166136261;
|
|
73
|
+
for (let i = 0; i < s.length; i++) {
|
|
74
|
+
h ^= s.charCodeAt(i);
|
|
75
|
+
h = Math.imul(h, 16777619);
|
|
76
|
+
}
|
|
77
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
78
|
+
}
|
package/tsconfig.json
ADDED