@suluk/cloudflare 0.1.1 → 0.1.2
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/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/ratelimit.ts +55 -0
- package/test/ratelimit.test.ts +57 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cloudflare",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Butter-smooth, API-driven provisioning + deployment for a Suluk app on Cloudflare — no wrangler CLI. A typed REST client + idempotent provisioners (D1, KV, R2, secrets) + the Workers module-script + static-assets upload flow, orchestrated into one deploy(). The platform that ships itself, shipping itself. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -9,3 +9,5 @@ export { provisionD1, queryD1, applyMigrations, provisionKvNamespace, provisionR
|
|
|
9
9
|
export { uploadAssets, assetHash, extractAssetRuleFiles, type AssetFile, type UploadSession, type AssetRuleFiles } from "./assets";
|
|
10
10
|
export { deployWorker, putCronTriggers, type DeployWorkerOptions, type WorkerBinding } from "./worker";
|
|
11
11
|
export { deploy, deployWith, type DeployPlan, type DeployResult, type DeployLog } from "./deploy";
|
|
12
|
+
// the production KV-backed RateLimitStore for @suluk/hono's enforceRateLimit (MemoryRateLimitStore is dev-only).
|
|
13
|
+
export { kvRateLimitStore, memoryRateLimitStore, type RateLimitStore, type ConsumeOptions, type ConsumeResult, type KvLike } from "./ratelimit";
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A KV-backed RateLimitStore — the production durable counter @suluk/hono's `enforceRateLimit` needs (its
|
|
3
|
+
* MemoryRateLimitStore is DEV-only; it doesn't coordinate across Workers isolates). Fixed-window counter in a
|
|
4
|
+
* Workers KV namespace, fail-OPEN to a fallback store on any KV blip so a KV outage never hard-blocks traffic.
|
|
5
|
+
*
|
|
6
|
+
* Structurally typed (no @suluk/hono dependency — the consume contract is tiny + stable), so the returned store
|
|
7
|
+
* plugs straight into enforceRateLimit({ store }). The KV binding is resolved LAZILY (a getter) because on Workers
|
|
8
|
+
* the binding isn't available at module-init — capture it on first request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ConsumeOptions { maxRequests: number; windowMs: number; now: number }
|
|
12
|
+
export interface ConsumeResult { limited: boolean; remaining: number; retryAfterMs: number }
|
|
13
|
+
/** Matches @suluk/hono's RateLimitStore (structural — satisfies enforceRateLimit's `store` without a package dep). */
|
|
14
|
+
export interface RateLimitStore { consume(key: string, opts: ConsumeOptions): Promise<ConsumeResult> }
|
|
15
|
+
/** The slice of the Workers KV API this needs (get/put with TTL). */
|
|
16
|
+
export interface KvLike { get(key: string): Promise<string | null>; put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void> }
|
|
17
|
+
|
|
18
|
+
/** A per-instance in-memory fixed-window store — the fail-open fallback (DEV only / KV-blip; not cross-isolate). */
|
|
19
|
+
export function memoryRateLimitStore(): RateLimitStore {
|
|
20
|
+
const m = new Map<string, { count: number; resetAt: number }>();
|
|
21
|
+
return {
|
|
22
|
+
async consume(key, o) {
|
|
23
|
+
let e = m.get(key);
|
|
24
|
+
if (!e || o.now > e.resetAt) e = { count: 1, resetAt: o.now + o.windowMs };
|
|
25
|
+
else e.count++;
|
|
26
|
+
m.set(key, e);
|
|
27
|
+
const limited = e.count > o.maxRequests;
|
|
28
|
+
return { limited, remaining: Math.max(0, o.maxRequests - e.count), retryAfterMs: limited ? o.windowMs : 0 };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a KV-backed RateLimitStore. `kv` is the namespace, or a getter (lazy — capture the binding on first request).
|
|
35
|
+
* Falls open to `opts.fallback` (default a per-instance memory store) when KV is absent or errors.
|
|
36
|
+
*/
|
|
37
|
+
export function kvRateLimitStore(kv: KvLike | undefined | (() => KvLike | undefined), opts: { fallback?: RateLimitStore } = {}): RateLimitStore {
|
|
38
|
+
const getKv = typeof kv === "function" ? kv : () => kv;
|
|
39
|
+
const fallback = opts.fallback ?? memoryRateLimitStore();
|
|
40
|
+
return {
|
|
41
|
+
async consume(key, o) {
|
|
42
|
+
const k = getKv();
|
|
43
|
+
if (!k) return fallback.consume(key, o);
|
|
44
|
+
try {
|
|
45
|
+
const raw = await k.get(key);
|
|
46
|
+
let e = raw ? (JSON.parse(raw) as { count: number; resetAt: number }) : null;
|
|
47
|
+
if (!e || o.now > e.resetAt) e = { count: 1, resetAt: o.now + o.windowMs };
|
|
48
|
+
else e.count++;
|
|
49
|
+
const limited = e.count > o.maxRequests;
|
|
50
|
+
await k.put(key, JSON.stringify(e), { expirationTtl: Math.max(60, Math.ceil(o.windowMs / 1000) + 5) });
|
|
51
|
+
return { limited, remaining: Math.max(0, o.maxRequests - e.count), retryAfterMs: limited ? o.windowMs : 0 };
|
|
52
|
+
} catch { return fallback.consume(key, o); } // KV blip → fail open
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kvRateLimitStore — the production KV-backed RateLimitStore. Fixed-window counting, window reset, lazy KV getter,
|
|
3
|
+
* and fail-open to the fallback on a missing binding / KV error.
|
|
4
|
+
*/
|
|
5
|
+
import { test, expect, describe } from "bun:test";
|
|
6
|
+
import { kvRateLimitStore, memoryRateLimitStore, type KvLike } from "../src/index";
|
|
7
|
+
|
|
8
|
+
function mockKv(): KvLike & { map: Map<string, string> } {
|
|
9
|
+
const map = new Map<string, string>();
|
|
10
|
+
return { map, async get(k) { return map.get(k) ?? null; }, async put(k, v) { map.set(k, v); } };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("kvRateLimitStore", () => {
|
|
14
|
+
test("counts within a fixed window and limits past maxRequests", async () => {
|
|
15
|
+
const s = kvRateLimitStore(mockKv());
|
|
16
|
+
const o = { maxRequests: 3, windowMs: 60000, now: 1000 };
|
|
17
|
+
expect((await s.consume("ip", o)).limited).toBe(false); // 1
|
|
18
|
+
expect((await s.consume("ip", o)).limited).toBe(false); // 2
|
|
19
|
+
const third = await s.consume("ip", o); // 3
|
|
20
|
+
expect(third.limited).toBe(false);
|
|
21
|
+
expect(third.remaining).toBe(0);
|
|
22
|
+
const fourth = await s.consume("ip", o); // 4 → over
|
|
23
|
+
expect(fourth.limited).toBe(true);
|
|
24
|
+
expect(fourth.retryAfterMs).toBe(60000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("resets after the window elapses (now > resetAt)", async () => {
|
|
28
|
+
const s = kvRateLimitStore(mockKv());
|
|
29
|
+
await s.consume("ip", { maxRequests: 1, windowMs: 1000, now: 1000 });
|
|
30
|
+
expect((await s.consume("ip", { maxRequests: 1, windowMs: 1000, now: 1500 })).limited).toBe(true); // same window
|
|
31
|
+
expect((await s.consume("ip", { maxRequests: 1, windowMs: 1000, now: 3000 })).limited).toBe(false); // new window
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("separate keys are independent", async () => {
|
|
35
|
+
const s = kvRateLimitStore(mockKv());
|
|
36
|
+
const o = { maxRequests: 1, windowMs: 60000, now: 1000 };
|
|
37
|
+
expect((await s.consume("a", o)).limited).toBe(false);
|
|
38
|
+
expect((await s.consume("b", o)).limited).toBe(false); // b's own bucket
|
|
39
|
+
expect((await s.consume("a", o)).limited).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("lazy getter: no binding yet → falls open to the fallback, then uses KV once present", async () => {
|
|
43
|
+
let kv: KvLike | undefined;
|
|
44
|
+
const fallback = memoryRateLimitStore();
|
|
45
|
+
const s = kvRateLimitStore(() => kv, { fallback });
|
|
46
|
+
const o = { maxRequests: 100, windowMs: 60000, now: 1000 };
|
|
47
|
+
expect((await s.consume("ip", o)).limited).toBe(false); // via fallback (kv undefined)
|
|
48
|
+
kv = mockKv(); // binding captured on a later request
|
|
49
|
+
expect((await s.consume("ip", o)).remaining).toBe(99); // now via KV (fresh count)
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("KV error fails OPEN to the fallback (never hard-blocks)", async () => {
|
|
53
|
+
const throwingKv: KvLike = { async get() { throw new Error("kv down"); }, async put() { throw new Error("kv down"); } };
|
|
54
|
+
const s = kvRateLimitStore(throwingKv);
|
|
55
|
+
expect((await s.consume("ip", { maxRequests: 1, windowMs: 60000, now: 1000 })).limited).toBe(false); // fell open
|
|
56
|
+
});
|
|
57
|
+
});
|