@suluk/cloudflare 0.1.0 → 0.1.1
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 +1 -1
- package/test/cloudflare.test.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cloudflare",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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,6 @@
|
|
|
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";
|
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
|
});
|