@suluk/cloudflare 0.1.0 → 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/assets.ts +28 -0
- package/src/deploy.ts +20 -4
- package/src/index.ts +3 -1
- package/src/ratelimit.ts +55 -0
- package/test/cloudflare.test.ts +29 -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/assets.ts
CHANGED
|
@@ -33,6 +33,34 @@ export interface UploadSession {
|
|
|
33
33
|
buckets?: string[][];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The result of splitting Workers-Assets rule files out of an asset list. */
|
|
37
|
+
export interface AssetRuleFiles {
|
|
38
|
+
/** the remaining files to actually upload + serve. */
|
|
39
|
+
assets: AssetFile[];
|
|
40
|
+
/** raw `_headers` file contents, if present — passed in the worker metadata's assets.config, NOT uploaded. */
|
|
41
|
+
_headers?: string;
|
|
42
|
+
/** raw `_redirects` file contents, if present. */
|
|
43
|
+
_redirects?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pull `_headers` / `_redirects` OUT of an asset list. Cloudflare Workers Static Assets does NOT serve these as files
|
|
48
|
+
* — it parses their raw text (sent in the worker metadata's `assets.config._headers` / `._redirects`) into the
|
|
49
|
+
* header/redirect rules the asset runtime applies. So they must be EXCLUDED from the upload manifest (else they'd
|
|
50
|
+
* serve as public 200 blobs and the rules would never activate) and their contents routed to the config instead.
|
|
51
|
+
* This mirrors exactly what wrangler does (excludes the two files, forwards their contents in the config).
|
|
52
|
+
*/
|
|
53
|
+
export function extractAssetRuleFiles(files: AssetFile[]): AssetRuleFiles {
|
|
54
|
+
const out: AssetRuleFiles = { assets: [] };
|
|
55
|
+
const dec = new TextDecoder();
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
if (f.path === "/_headers") out._headers = dec.decode(f.bytes as unknown as Uint8Array);
|
|
58
|
+
else if (f.path === "/_redirects") out._redirects = dec.decode(f.bytes as unknown as Uint8Array);
|
|
59
|
+
else out.assets.push(f);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
/**
|
|
37
65
|
* Upload a set of static assets; returns the completion JWT for the worker metadata, or `null` when there are none.
|
|
38
66
|
* When every file is already cached server-side the session returns no buckets and its own jwt IS the completion token.
|
package/src/deploy.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { CloudflareClient, type CloudflareClientOptions } from "./client";
|
|
8
8
|
import { provisionD1, provisionKvNamespace, provisionR2Bucket, applyMigrations, putSecrets, type Migration } from "./resources";
|
|
9
|
-
import { uploadAssets, type AssetFile } from "./assets";
|
|
9
|
+
import { uploadAssets, extractAssetRuleFiles, type AssetFile } from "./assets";
|
|
10
10
|
import { deployWorker, putCronTriggers, type WorkerBinding } from "./worker";
|
|
11
11
|
|
|
12
12
|
export interface DeployPlan {
|
|
@@ -73,13 +73,29 @@ export async function deploy(cf: CloudflareClient, plan: DeployPlan, log: Deploy
|
|
|
73
73
|
for (const b of plan.r2 ?? []) { const bk = await provisionR2Bucket(cf, b.bucketName); bindings.push({ type: "r2_bucket", name: b.binding, bucket_name: bk.name }); r2.push({ binding: b.binding, name: bk.name }); log(`R2 "${bk.name}" bound`); }
|
|
74
74
|
|
|
75
75
|
let assetsJwt: string | null = null;
|
|
76
|
-
|
|
76
|
+
let assetsConfig = plan.assetsConfig;
|
|
77
|
+
let assetsUploaded = 0;
|
|
78
|
+
if (plan.assets?.length) {
|
|
79
|
+
// `_headers`/`_redirects` are NOT uploaded as files — their raw text rides in assets.config and Cloudflare parses
|
|
80
|
+
// them server-side into header/redirect rules (the asset runtime then applies them). Excluding them from the
|
|
81
|
+
// manifest is load-bearing: an uploaded /_headers would serve as a public 200 blob AND never activate as rules.
|
|
82
|
+
const { assets, _headers, _redirects } = extractAssetRuleFiles(plan.assets);
|
|
83
|
+
if (_headers != null || _redirects != null) {
|
|
84
|
+
assetsConfig = { ...assetsConfig, ...(_headers != null ? { _headers } : {}), ...(_redirects != null ? { _redirects } : {}) };
|
|
85
|
+
}
|
|
86
|
+
assetsUploaded = assets.length;
|
|
87
|
+
if (assets.length) {
|
|
88
|
+
assetsJwt = await uploadAssets(cf, plan.scriptName, assets);
|
|
89
|
+
const rules = [_headers != null ? "_headers" : "", _redirects != null ? "_redirects" : ""].filter(Boolean).join("+");
|
|
90
|
+
log(`assets: ${assets.length} files uploaded${rules ? ` (${rules} → config rules)` : ""}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
77
93
|
|
|
78
94
|
await deployWorker(cf, {
|
|
79
95
|
name: plan.scriptName, module: plan.module, mainModule: plan.mainModule,
|
|
80
96
|
compatibilityDate: plan.compatibilityDate, compatibilityFlags: plan.compatibilityFlags,
|
|
81
97
|
bindings, vars: plan.vars,
|
|
82
|
-
assets: { jwt: assetsJwt, binding: plan.assetsBinding, config:
|
|
98
|
+
assets: { jwt: assetsJwt, binding: plan.assetsBinding, config: assetsConfig },
|
|
83
99
|
observability: plan.observability,
|
|
84
100
|
});
|
|
85
101
|
log(`worker "${plan.scriptName}" deployed`);
|
|
@@ -89,7 +105,7 @@ export async function deploy(cf: CloudflareClient, plan: DeployPlan, log: Deploy
|
|
|
89
105
|
|
|
90
106
|
if (plan.crons?.length) { await putCronTriggers(cf, plan.scriptName, plan.crons); log(`crons: ${plan.crons.join(" · ")}`); }
|
|
91
107
|
|
|
92
|
-
return { accountId, scriptName: plan.scriptName, d1, kv, r2, assetsUploaded
|
|
108
|
+
return { accountId, scriptName: plan.scriptName, d1, kv, r2, assetsUploaded, secretsSet, crons: plan.crons ?? [] };
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
/** Convenience: build a client from token/account options and run a deploy. */
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { CloudflareClient, CloudflareError, type CloudflareClientOptions, type RequestOptions } from "./client";
|
|
8
8
|
export { provisionD1, queryD1, applyMigrations, provisionKvNamespace, provisionR2Bucket, putSecret, putSecrets, type D1Database, type KvNamespace, type Migration } from "./resources";
|
|
9
|
-
export { uploadAssets, assetHash, type AssetFile, type UploadSession } from "./assets";
|
|
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
|
+
}
|
package/test/cloudflare.test.ts
CHANGED
|
@@ -132,4 +132,33 @@ describe("deploy() — full orchestration in dependency order", () => {
|
|
|
132
132
|
expect(idx(/POST .*\/d1\/database\/db_1\/query/)).toBeLessThan(idx(/PUT .*\/workers\/scripts\/saasuluk$/));
|
|
133
133
|
expect(idx(/PUT .*\/workers\/scripts\/saasuluk$/)).toBeLessThan(idx(/\/secrets$/));
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test("routes _headers/_redirects into assets.config and EXCLUDES them from the upload manifest", async () => {
|
|
137
|
+
const { fetch, calls } = mockCf([
|
|
138
|
+
[/GET \/accounts$/, [{ id: "acct_1" }]],
|
|
139
|
+
[/POST .*\/assets-upload-session$/, { jwt: "session_jwt", buckets: [] }],
|
|
140
|
+
[/PUT .*\/workers\/scripts\/saasuluk$/, { id: "saasuluk" }],
|
|
141
|
+
]);
|
|
142
|
+
const cf = new CloudflareClient({ apiToken: "t", fetch });
|
|
143
|
+
const assets: AssetFile[] = [
|
|
144
|
+
{ path: "/_headers", bytes: new TextEncoder().encode("/_astro/*\n Cache-Control: public, max-age=31536000, immutable\n"), contentType: "application/octet-stream" },
|
|
145
|
+
{ path: "/_redirects", bytes: new TextEncoder().encode("/old /new 301\n"), contentType: "application/octet-stream" },
|
|
146
|
+
{ path: "/index.html", bytes: new TextEncoder().encode("<!doctype html>"), contentType: "text/html" },
|
|
147
|
+
];
|
|
148
|
+
const res = await deploy(cf, { scriptName: "saasuluk", module: "m", compatibilityDate: "2026-06-01", assets, assetsConfig: { html_handling: "auto-trailing-slash" } });
|
|
149
|
+
|
|
150
|
+
expect(res.assetsUploaded).toBe(1); // only /index.html — the two rule files are NOT uploaded
|
|
151
|
+
|
|
152
|
+
// the upload-session manifest must contain ONLY /index.html (rule files excluded so they never serve as blobs)
|
|
153
|
+
const session = calls.find((c) => /assets-upload-session$/.test(c.path))!;
|
|
154
|
+
const manifest = JSON.parse(session.body as string).manifest;
|
|
155
|
+
expect(Object.keys(manifest)).toEqual(["/index.html"]);
|
|
156
|
+
|
|
157
|
+
// the worker metadata carried the raw rule-file contents in assets.config, alongside html_handling
|
|
158
|
+
const put = calls.find((c) => c.method === "PUT" && /\/workers\/scripts\/saasuluk$/.test(c.path))!;
|
|
159
|
+
const meta = JSON.parse(await (put.body as FormData).get("metadata")!.text());
|
|
160
|
+
expect(meta.assets.config.html_handling).toBe("auto-trailing-slash");
|
|
161
|
+
expect(meta.assets.config._headers).toContain("immutable");
|
|
162
|
+
expect(meta.assets.config._redirects).toContain("/old /new 301");
|
|
163
|
+
});
|
|
135
164
|
});
|
|
@@ -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
|
+
});
|